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