Merge branch 'MDL-59950_master' of git://github.com/dmonllao/moodle
[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      * Constant for this cognitive level.
65      */
66     const COGNITIVE_LEVEL_1 = 1;
68     /**
69      * Constant for this cognitive level.
70      */
71     const COGNITIVE_LEVEL_2 = 2;
73     /**
74      * Constant for this cognitive level.
75      */
76     const COGNITIVE_LEVEL_3 = 3;
78     /**
79      * Constant for this cognitive level.
80      */
81     const COGNITIVE_LEVEL_4 = 4;
83     /**
84      * Constant for this cognitive level.
85      */
86     const COGNITIVE_LEVEL_5 = 5;
88     /**
89      * Constant for this social level.
90      */
91     const SOCIAL_LEVEL_1 = 1;
93     /**
94      * Constant for this social level.
95      */
96     const SOCIAL_LEVEL_2 = 2;
98     /**
99      * Constant for this social level.
100      */
101     const SOCIAL_LEVEL_3 = 3;
103     /**
104      * Constant for this social level.
105      */
106     const SOCIAL_LEVEL_4 = 4;
108     /**
109      * Constant for this social level.
110      */
111     const SOCIAL_LEVEL_5 = 5;
113     /**
114      * Max cognitive depth level accepted.
115      */
116     const MAX_COGNITIVE_LEVEL = 5;
118     /**
119      * Max social breadth level accepted.
120      */
121     const MAX_SOCIAL_LEVEL = 5;
123     /**
124      * Fetch the course grades of this activity type instances.
125      *
126      * @param \core_analytics\analysable $analysable
127      * @return void
128      */
129     public function fill_per_analysable_caches(\core_analytics\analysable $analysable) {
131         // Better to check it, we can not be 100% it will be a \core_analytics\course object.
132         if ($analysable instanceof \core_analytics\course) {
133             $this->fetch_student_grades($analysable);
134         }
135     }
137     /**
138      * Returns the activity type. No point in changing this class in children classes.
139      *
140      * @var string The activity name (e.g. assign or quiz)
141      */
142     public final function get_activity_type() {
143         $class = get_class($this);
144         $package = stristr($class, "\\", true);
145         $type = str_replace("mod_", "", $package);
146         if ($type === $package) {
147             throw new \coding_exception("$class does not belong to any module specific namespace");
148         }
149         return $type;
150     }
152     /**
153      * Returns the potential level of cognitive depth.
154      *
155      * @param \cm_info $cm
156      * @return int
157      */
158     public function get_cognitive_depth_level(\cm_info $cm) {
159         throw new \coding_exception('Overwrite get_cognitive_depth_level method to set your activity potential cognitive ' .
160             'depth level');
161     }
163     /**
164      * Returns the potential level of social breadth.
165      *
166      * @param \cm_info $cm
167      * @return int
168      */
169     public function get_social_breadth_level(\cm_info $cm) {
170         throw new \coding_exception('Overwrite get_social_breadth_level method to set your activity potential social ' .
171             'breadth level');
172     }
174     /**
175      * required_sample_data
176      *
177      * @return string[]
178      */
179     public static function required_sample_data() {
180         // Only course because the indicator is valid even without students.
181         return array('course');
182     }
184     /**
185      * Do activity logs contain any log of user in this context?
186      *
187      * If user is empty we look for any log in this context.
188      *
189      * @param int $contextid
190      * @param \stdClass|false $user
191      * @return bool
192      */
193     protected final function any_log($contextid, $user) {
194         if (empty($this->activitylogs[$contextid])) {
195             return false;
196         }
198         // Someone interacted with the activity if there is no user or the user interacted with the
199         // activity if there is a user.
200         if (empty($user) ||
201                 (!empty($user) && !empty($this->activitylogs[$contextid][$user->id]))) {
202             return true;
203         }
205         return false;
206     }
208     /**
209      * Do activity logs contain any write log of user in this context?
210      *
211      * If user is empty we look for any write log in this context.
212      *
213      * @param int $contextid
214      * @param \stdClass|false $user
215      * @return bool
216      */
217     protected final function any_write_log($contextid, $user) {
218         if (empty($this->activitylogs[$contextid])) {
219             return false;
220         }
222         // No specific user, we look at all activity logs.
223         $it = $this->activitylogs[$contextid];
224         if ($user) {
225             if (empty($this->activitylogs[$contextid][$user->id])) {
226                 return false;
227             }
228             $it = array($user->id => $this->activitylogs[$contextid][$user->id]);
229         }
230         foreach ($it as $events) {
231             foreach ($events as $log) {
232                 if ($log->crud === 'c' || $log->crud === 'u') {
233                     return true;
234                 }
235             }
236         }
238         return false;
239     }
241     /**
242      * Is there any feedback activity log for this user in this context?
243      *
244      * This method returns true if $user is empty and there is any feedback activity logs.
245      *
246      * @param string $action
247      * @param \cm_info $cm
248      * @param int $contextid
249      * @param \stdClass|false $user
250      * @return bool
251      */
252     protected function any_feedback($action, \cm_info $cm, $contextid, $user) {
254         if (!in_array($action, ['submitted', 'replied', 'viewed'])) {
255             throw new \coding_exception('Provided action "' . $action . '" is not valid.');
256         }
258         if (empty($this->activitylogs[$contextid])) {
259             return false;
260         }
262         if (empty($this->grades[$contextid]) && $this->feedback_check_grades()) {
263             // If there are no grades there is no feedback.
264             return false;
265         }
267         $it = $this->activitylogs[$contextid];
268         if ($user) {
269             if (empty($this->activitylogs[$contextid][$user->id])) {
270                 return false;
271             }
272             $it = array($user->id => $this->activitylogs[$contextid][$user->id]);
273         }
275         foreach ($this->activitylogs[$contextid] as $userid => $events) {
276             $methodname = 'feedback_' . $action;
277             if ($this->{$methodname}($cm, $contextid, $userid)) {
278                 return true;
279             }
280             // If it wasn't viewed try with the next user.
281         }
282         return false;
283     }
285     /**
286      * $cm is used for this method overrides.
287      *
288      * This function must be fast.
289      *
290      * @param \cm_info $cm
291      * @param mixed $contextid
292      * @param mixed $userid
293      * @param int $after Timestamp, defaults to the graded date or false if we don't check the date.
294      * @return bool
295      */
296     protected function feedback_viewed(\cm_info $cm, $contextid, $userid, $after = null) {
297         return $this->feedback_post_action($cm, $contextid, $userid, $this->feedback_viewed_events(), $after);
298     }
300     /**
301      * $cm is used for this method overrides.
302      *
303      * This function must be fast.
304      *
305      * @param \cm_info $cm
306      * @param mixed $contextid
307      * @param mixed $userid
308      * @param int $after Timestamp, defaults to the graded date or false if we don't check the date.
309      * @return bool
310      */
311     protected function feedback_replied(\cm_info $cm, $contextid, $userid, $after = null) {
312         return $this->feedback_post_action($cm, $contextid, $userid, $this->feedback_replied_events(), $after);
313     }
315     /**
316      * $cm is used for this method overrides.
317      *
318      * This function must be fast.
319      *
320      * @param \cm_info $cm
321      * @param mixed $contextid
322      * @param mixed $userid
323      * @param int $after Timestamp, defaults to the graded date or false if we don't check the date.
324      * @return bool
325      */
326     protected function feedback_submitted(\cm_info $cm, $contextid, $userid, $after = null) {
327         return $this->feedback_post_action($cm, $contextid, $userid, $this->feedback_submitted_events(), $after);
328     }
330     /**
331      * Returns the list of events that involve viewing feedback from other users.
332      *
333      * @return string[]
334      */
335     protected function feedback_viewed_events() {
336         throw new \coding_exception('Activities with a potential cognitive or social level that include viewing feedback ' .
337             'should define "feedback_viewed_events" method or should override feedback_viewed method.');
338     }
340     /**
341      * Returns the list of events that involve replying to feedback from other users.
342      *
343      * @return string[]
344      */
345     protected function feedback_replied_events() {
346         throw new \coding_exception('Activities with a potential cognitive or social level that include replying to feedback ' .
347             'should define "feedback_replied_events" method or should override feedback_replied method.');
348     }
350     /**
351      * Returns the list of events that involve submitting something after receiving feedback from other users.
352      *
353      * @return string[]
354      */
355     protected function feedback_submitted_events() {
356         throw new \coding_exception('Activities with a potential cognitive or social level that include viewing feedback ' .
357             'should define "feedback_submitted_events" method or should override feedback_submitted method.');
358     }
360     /**
361      * Whether this user in this context did any of the provided actions (events)
362      *
363      * @param \cm_info $cm
364      * @param int $contextid
365      * @param int $userid
366      * @param string[] $eventnames
367      * @param int|false $after
368      * @return bool
369      */
370     protected function feedback_post_action(\cm_info $cm, $contextid, $userid, $eventnames, $after = null) {
371         if ($after === null) {
372             if ($this->feedback_check_grades()) {
373                 if (!$after = $this->get_graded_date($contextid, $userid)) {
374                     return false;
375                 }
376             } else {
377                 $after = false;
378             }
379         }
381         if (empty($this->activitylogs[$contextid][$userid])) {
382             return false;
383         }
385         foreach ($eventnames as $eventname) {
386             if (!$after) {
387                 if (!empty($this->activitylogs[$contextid][$userid][$eventname])) {
388                     // If we don't care about when the feedback has been seen we consider this enough.
389                     return true;
390                 }
391             } else {
392                 if (empty($this->activitylogs[$contextid][$userid][$eventname])) {
393                     continue;
394                 }
395                 $timestamps = $this->activitylogs[$contextid][$userid][$eventname]->timecreated;
396                 // Faster to start by the end.
397                 rsort($timestamps);
398                 foreach ($timestamps as $timestamp) {
399                     if ($timestamp > $after) {
400                         return true;
401                     }
402                 }
403             }
404         }
405         return false;
406     }
408     /**
409      * Returns the date a user was graded.
410      *
411      * @param int $contextid
412      * @param int $userid
413      * @param bool $checkfeedback Check that the student was graded or check that feedback was given
414      * @return int|false
415      */
416     protected function get_graded_date($contextid, $userid, $checkfeedback = false) {
417         if (empty($this->grades[$contextid][$userid])) {
418             return false;
419         }
420         foreach ($this->grades[$contextid][$userid] as $gradeitemid => $gradeitem) {
422             // We check that either feedback or the grade is set.
423             if (($checkfeedback && $gradeitem->feedback) || $gradeitem->grade) {
425                 // Grab the first graded date.
426                 if ($gradeitem->dategraded && (empty($after) || $gradeitem->dategraded < $after)) {
427                     $after = $gradeitem->dategraded;
428                 }
429             }
430         }
432         if (!isset($after)) {
433             // False if there are no graded items.
434             return false;
435         }
437         return $after;
438     }
440     /**
441      * Returns the activities the user had access to between a time period.
442      *
443      * @param int $sampleid
444      * @param string $tablename
445      * @param int $starttime
446      * @param int $endtime
447      * @return array
448      */
449     protected function get_student_activities($sampleid, $tablename, $starttime, $endtime) {
451         // May not be available.
452         $user = $this->retrieve('user', $sampleid);
454         if ($this->course === null) {
455             // The indicator scope is a range, so all activities belong to the same course.
456             $this->course = \core_analytics\course::instance($this->retrieve('course', $sampleid));
457         }
459         if ($this->activitylogs === null) {
460             // Fetch all activity logs in each activity in the course, not restricted to a specific sample so we can cache it.
462             $courseactivities = $this->course->get_all_activities($this->get_activity_type());
464             // Null if no activities of this type in this course.
465             if (empty($courseactivities)) {
466                 $this->activitylogs = false;
467                 return null;
468             }
469             $this->activitylogs = $this->fetch_activity_logs($courseactivities, $starttime, $endtime);
470         }
472         if ($this->grades === null) {
473             // Even if this is probably already filled during fill_per_analysable_caches.
474             $this->fetch_student_grades($this->course);
475         }
477         if ($cm = $this->retrieve('cm', $sampleid)) {
478             // Samples are at cm level or below.
479             $useractivities = array(\context_module::instance($cm->id)->id => $cm);
480         } else {
481             // All course activities.
482             $useractivities = $this->course->get_activities($this->get_activity_type(), $starttime, $endtime, $user);
483         }
485         return $useractivities;
486     }
488     /**
489      * Fetch acitivity logs from database
490      *
491      * @param array $activities
492      * @param int $starttime
493      * @param int $endtime
494      * @return array
495      */
496     protected function fetch_activity_logs($activities, $starttime = false, $endtime = false) {
497         global $DB;
499         // Filter by context to use the db table index.
500         list($contextsql, $contextparams) = $DB->get_in_or_equal(array_keys($activities), SQL_PARAMS_NAMED);
501         $select = "contextid $contextsql AND timecreated > :starttime AND timecreated <= :endtime";
502         $params = $contextparams + array('starttime' => $starttime, 'endtime' => $endtime);
504         // Pity that we need to pass through logging readers API when most of the people just uses the standard one.
505         if (!$logstore = \core_analytics\manager::get_analytics_logstore()) {
506             throw new \coding_exception('No log store available');
507         }
508         $events = $logstore->get_events_select_iterator($select, $params, 'timecreated ASC', 0, 0);
510         // Returs the logs organised by contextid, userid and eventname so it is easier to calculate activities data later.
511         // At the same time we want to keep this array reasonably "not-massive".
512         $processedevents = array();
513         foreach ($events as $event) {
514             if (!isset($processedevents[$event->contextid])) {
515                 $processedevents[$event->contextid] = array();
516             }
517             if (!isset($processedevents[$event->contextid][$event->userid])) {
518                 $processedevents[$event->contextid][$event->userid] = array();
519             }
521             // Contextid and userid have already been used to index the events, the next field to index by is eventname:
522             // crud is unique per eventname, courseid is the same for all records and we append timecreated.
523             if (!isset($processedevents[$event->contextid][$event->userid][$event->eventname])) {
525                 // Remove all data that can change between events of the same type.
526                 $data = (object)$event->get_data();
527                 unset($data->id);
528                 unset($data->anonymous);
529                 unset($data->relateduserid);
530                 unset($data->other);
531                 unset($data->origin);
532                 unset($data->ip);
533                 $processedevents[$event->contextid][$event->userid][$event->eventname] = $data;
534                 // We want timecreated attribute to be an array containing all user access times.
535                 $processedevents[$event->contextid][$event->userid][$event->eventname]->timecreated = array();
536             }
538             // Add the event timecreated.
539             $processedevents[$event->contextid][$event->userid][$event->eventname]->timecreated[] = intval($event->timecreated);
540         }
541         $events->close();
543         return $processedevents;
544     }
546     /**
547      * Whether grades should be checked or not when looking for feedback.
548      *
549      * @return bool
550      */
551     protected function feedback_check_grades() {
552         return true;
553     }
555     /**
556      * Calculates the cognitive depth of a sample.
557      *
558      * @param int $sampleid
559      * @param string $tablename
560      * @param int $starttime
561      * @param int $endtime
562      * @return float|int|null
563      * @throws \coding_exception
564      */
565     protected function cognitive_calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) {
567         // May not be available.
568         $user = $this->retrieve('user', $sampleid);
570         if (!$useractivities = $this->get_student_activities($sampleid, $tablename, $starttime, $endtime)) {
571             // Null if no activities.
572             return null;
573         }
575         $scoreperactivity = (self::get_max_value() - self::get_min_value()) / count($useractivities);
577         $score = self::get_min_value();
579         // Iterate through the module activities/resources which due date is part of this time range.
580         foreach ($useractivities as $contextid => $cm) {
582             $potentiallevel = $this->get_cognitive_depth_level($cm);
583             if (!is_int($potentiallevel)
584                     || $potentiallevel > self::MAX_COGNITIVE_LEVEL
585                     || $potentiallevel < self::COGNITIVE_LEVEL_1) {
586                 throw new \coding_exception('Activities\' potential cognitive depth go from 1 to 5.');
587             }
588             $scoreperlevel = $scoreperactivity / $potentiallevel;
590             switch ($potentiallevel) {
591                 case self::COGNITIVE_LEVEL_5:
592                     // Cognitive level 5 is to submit after feedback.
593                     if ($this->any_feedback('submitted', $cm, $contextid, $user)) {
594                         $score += $scoreperlevel * 5;
595                         break;
596                     }
597                     // The user didn't reach the activity max cognitive depth, continue with level 2.
599                 case self::COGNITIVE_LEVEL_4:
600                     // Cognitive level 4 is to comment on feedback.
601                     if ($this->any_feedback('replied', $cm, $contextid, $user)) {
602                         $score += $scoreperlevel * 4;
603                         break;
604                     }
605                     // The user didn't reach the activity max cognitive depth, continue with level 2.
607                 case self::COGNITIVE_LEVEL_3:
608                     // Cognitive level 3 is to view feedback.
610                     if ($this->any_feedback('viewed', $cm, $contextid, $user)) {
611                         // Max score for level 3.
612                         $score += $scoreperlevel * 3;
613                         break;
614                     }
615                     // The user didn't reach the activity max cognitive depth, continue with level 2.
617                 case self::COGNITIVE_LEVEL_2:
618                     // Cognitive depth level 2 is to submit content.
620                     if ($this->any_write_log($contextid, $user)) {
621                         $score += $scoreperlevel * 2;
622                         break;
623                     }
624                     // The user didn't reach the activity max cognitive depth, continue with level 1.
626                 case self::COGNITIVE_LEVEL_1:
627                     // Cognitive depth level 1 is just accessing the activity.
629                     if ($this->any_log($contextid, $user)) {
630                         $score += $scoreperlevel;
631                     }
633                 default:
634             }
635         }
637         // To avoid decimal problems.
638         if ($score > self::MAX_VALUE) {
639             return self::MAX_VALUE;
640         } else if ($score < self::MIN_VALUE) {
641             return self::MIN_VALUE;
642         }
643         return $score;
644     }
646     /**
647      * Calculates the social breadth of a sample.
648      *
649      * @param int $sampleid
650      * @param string $tablename
651      * @param int $starttime
652      * @param int $endtime
653      * @return float|int|null
654      */
655     protected function social_calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) {
657         // May not be available.
658         $user = $this->retrieve('user', $sampleid);
660         if (!$useractivities = $this->get_student_activities($sampleid, $tablename, $starttime, $endtime)) {
661             // Null if no activities.
662             return null;
663         }
665         $scoreperactivity = (self::get_max_value() - self::get_min_value()) / count($useractivities);
667         $score = self::get_min_value();
669         foreach ($useractivities as $contextid => $cm) {
671             $potentiallevel = $this->get_social_breadth_level($cm);
672             if (!is_int($potentiallevel)
673                     || $potentiallevel > self::MAX_SOCIAL_LEVEL
674                     || $potentiallevel < self::SOCIAL_LEVEL_1) {
675                 throw new \coding_exception('Activities\' potential social breadth go from 1 to ' .
676                     community_of_inquiry_activity::MAX_SOCIAL_LEVEL . '.');
677             }
678             $scoreperlevel = $scoreperactivity / $potentiallevel;
679             switch ($potentiallevel) {
680                 case self::SOCIAL_LEVEL_2:
681                 case self::SOCIAL_LEVEL_3:
682                 case self::SOCIAL_LEVEL_4:
683                 case self::SOCIAL_LEVEL_5:
684                     // Core activities social breadth only reaches level 2, until core activities social
685                     // breadth do not reach level 5 we limit it to what we currently support, which is level 2.
687                     // Social breadth level 2 is to view feedback. (Same as cognitive level 3).
689                     if ($this->any_feedback('viewed', $cm, $contextid, $user)) {
690                         // Max score for level 2.
691                         $score += $scoreperlevel * 2;
692                         break;
693                     }
694                     // The user didn't reach the activity max social breadth, continue with level 1.
696                 case self::SOCIAL_LEVEL_1:
697                     // Social breadth level 1 is just accessing the activity.
698                     if ($this->any_log($contextid, $user)) {
699                         $score += $scoreperlevel;
700                     }
701             }
703         }
705         // To avoid decimal problems.
706         if ($score > self::MAX_VALUE) {
707             return self::MAX_VALUE;
708         } else if ($score < self::MIN_VALUE) {
709             return self::MIN_VALUE;
710         }
711         return $score;
712     }
714     /**
715      * calculate_sample
716      *
717      * @throws \coding_exception
718      * @param int $sampleid
719      * @param string $tablename
720      * @param int $starttime
721      * @param int $endtime
722      * @return float|int|null
723      */
724     protected function calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) {
725         if ($this->get_indicator_type() == self::INDICATOR_COGNITIVE) {
726             return $this->cognitive_calculate_sample($sampleid, $tablename, $starttime, $endtime);
727         } else if ($this->get_indicator_type() == self::INDICATOR_SOCIAL) {
728             return $this->social_calculate_sample($sampleid, $tablename, $starttime, $endtime);
729         }
730         throw new \coding_exception("Indicator type is invalid.");
731     }
733     /**
734      * Gets the course student grades.
735      *
736      * @param \core_analytics\course $course
737      * @return void
738      */
739     protected function fetch_student_grades(\core_analytics\course $course) {
740         $courseactivities = $course->get_all_activities($this->get_activity_type());
741         $this->grades = $course->get_student_grades($courseactivities);
742     }
744     /**
745      * Defines indicator type.
746      *
747      * @return string
748      */
749     abstract public function get_indicator_type();