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