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