MDL-59030 analytics: Social breadth accepting more than level 2
[moodle.git] / analytics / classes / local / indicator / community_of_inquiry_activity.php
CommitLineData
369389c9
DM
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/>.
16
17/**
f67f35f3 18 * Community of inquiry abstract indicator.
369389c9
DM
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 */
24
25namespace core_analytics\local\indicator;
26
27defined('MOODLE_INTERNAL') || die();
28
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 */
36abstract class community_of_inquiry_activity extends linear {
37
413f19bc
DM
38 /**
39 * @var \core_analytics\course
40 */
369389c9 41 protected $course = null;
413f19bc 42
369389c9 43 /**
369389c9
DM
44 * @var array Array of logs by [contextid][userid]
45 */
46 protected $activitylogs = null;
47
48 /**
49 * @var array Array of grades by [contextid][userid]
50 */
51 protected $grades = null;
52
022f3f07 53 /**
413f19bc 54 * Constant cognitive indicator type.
022f3f07
AA
55 */
56 const INDICATOR_COGNITIVE = "cognitve";
57
58 /**
413f19bc 59 * Constant social indicator type.
022f3f07
AA
60 */
61 const INDICATOR_SOCIAL = "social";
62
edee629d
DM
63 /**
64 * Max cognitive depth level accepted.
65 */
66 const MAX_COGNITIVE_LEVEL = 5;
67
68 /**
69 * Max social breadth level accepted.
70 */
f07d93de 71 const MAX_SOCIAL_LEVEL = 5;
edee629d 72
369389c9 73 /**
5846c72c
AA
74 * Returns the activity type. No point in changing this class in children classes.
75 *
369389c9
DM
76 * @var string The activity name (e.g. assign or quiz)
77 */
edee629d 78 public final function get_activity_type() {
5846c72c
AA
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 }
369389c9 87
413f19bc
DM
88 /**
89 * Returns the potential level of cognitive depth.
90 *
91 * @param \cm_info $cm
92 * @return int
93 */
edee629d 94 public function get_cognitive_depth_level(\cm_info $cm) {
369389c9
DM
95 throw new \coding_exception('Overwrite get_cognitive_depth_level method to set your activity potential cognitive ' .
96 'depth level');
97 }
98
413f19bc
DM
99 /**
100 * Returns the potential level of social breadth.
101 *
102 * @param \cm_info $cm
103 * @return int
104 */
edee629d 105 public function get_social_breadth_level(\cm_info $cm) {
1eb6c52b
AA
106 throw new \coding_exception('Overwrite get_social_breadth_level method to set your activity potential social ' .
107 'breadth level');
108 }
109
413f19bc
DM
110 /**
111 * required_sample_data
112 *
113 * @return string[]
114 */
369389c9
DM
115 public static function required_sample_data() {
116 // Only course because the indicator is valid even without students.
117 return array('course');
118 }
119
413f19bc
DM
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 */
369389c9
DM
129 protected final function any_log($contextid, $user) {
130 if (empty($this->activitylogs[$contextid])) {
131 return false;
132 }
133
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 }
140
141 return false;
142 }
143
413f19bc
DM
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 */
369389c9
DM
153 protected final function any_write_log($contextid, $user) {
154 if (empty($this->activitylogs[$contextid])) {
155 return false;
156 }
157
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 }
f67f35f3
DM
166 foreach ($it as $events) {
167 foreach ($events as $log) {
369389c9
DM
168 if ($log->crud === 'c' || $log->crud === 'u') {
169 return true;
170 }
171 }
172 }
173
174 return false;
175 }
176
413f19bc
DM
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 */
369389c9 188 protected function any_feedback($action, \cm_info $cm, $contextid, $user) {
1611308b 189
0eb59440 190 if (!in_array($action, ['submitted', 'replied', 'viewed'])) {
1611308b
DM
191 throw new \coding_exception('Provided action "' . $action . '" is not valid.');
192 }
193
369389c9
DM
194 if (empty($this->activitylogs[$contextid])) {
195 return false;
196 }
197
198 if (empty($this->grades[$contextid]) && $this->feedback_check_grades()) {
199 // If there are no grades there is no feedback.
200 return false;
201 }
202
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 }
210
f67f35f3 211 foreach ($this->activitylogs[$contextid] as $userid => $events) {
369389c9
DM
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 }
220
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 }
235
413f19bc
DM
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 */
369389c9
DM
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 }
250
413f19bc
DM
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 */
369389c9
DM
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 }
265
413f19bc
DM
266 /**
267 * Returns the list of events that involve viewing feedback from other users.
268 *
269 * @return string[]
270 */
369389c9 271 protected function feedback_viewed_events() {
f67f35f3
DM
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.');
369389c9
DM
274 }
275
413f19bc
DM
276 /**
277 * Returns the list of events that involve replying to feedback from other users.
278 *
279 * @return string[]
280 */
369389c9 281 protected function feedback_replied_events() {
f67f35f3
DM
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.');
369389c9
DM
284 }
285
413f19bc
DM
286 /**
287 * Returns the list of events that involve submitting something after receiving feedback from other users.
288 *
289 * @return string[]
290 */
369389c9 291 protected function feedback_submitted_events() {
f67f35f3
DM
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.');
369389c9
DM
294 }
295
413f19bc
DM
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 */
369389c9
DM
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 }
316
317 if (empty($this->activitylogs[$contextid][$userid])) {
318 return false;
319 }
320
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 }
343
344 /**
413f19bc 345 * Returns the date a user was graded.
369389c9
DM
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) {
357
358 // We check that either feedback or the grade is set.
359 if (($checkfeedback && $gradeitem->feedback) || $gradeitem->grade) {
360
361 // Grab the first graded date.
362 if ($gradeitem->dategraded && (empty($after) || $gradeitem->dategraded < $after)) {
363 $after = $gradeitem->dategraded;
364 }
365 }
366 }
367
368 if (!isset($after)) {
369 // False if there are no graded items.
370 return false;
371 }
372
373 return $after;
374 }
375
413f19bc
DM
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 */
369389c9
DM
385 protected function get_student_activities($sampleid, $tablename, $starttime, $endtime) {
386
387 // May not be available.
388 $user = $this->retrieve('user', $sampleid);
389
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 }
394
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.
397
398 $courseactivities = $this->course->get_all_activities($this->get_activity_type());
399
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 }
407
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 }
412
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 }
420
421 return $useractivities;
422 }
423
413f19bc
DM
424 /**
425 * Fetch acitivity logs from database
426 *
427 * @param array $activities
428 * @param int $starttime
429 * @param int $endtime
430 * @return array
431 */
369389c9
DM
432 protected function fetch_activity_logs($activities, $starttime = false, $endtime = false) {
433 global $DB;
434
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);
369389c9 437 $select = "contextid $contextsql AND timecreated > :starttime AND timecreated <= :endtime";
369389c9 438 $params = $contextparams + array('starttime' => $starttime, 'endtime' => $endtime);
f67f35f3
DM
439
440 // Pity that we need to pass through logging readers API when most of the people just uses the standard one.
2db6e981
DM
441 if (!$logstore = \core_analytics\manager::get_analytics_logstore()) {
442 throw new \coding_exception('No log store available');
443 }
f67f35f3 444 $events = $logstore->get_events_select_iterator($select, $params, 'timecreated ASC', 0, 0);
369389c9
DM
445
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".
f67f35f3
DM
448 $processedevents = array();
449 foreach ($events as $event) {
450 if (!isset($processedevents[$event->contextid])) {
451 $processedevents[$event->contextid] = array();
369389c9 452 }
f67f35f3
DM
453 if (!isset($processedevents[$event->contextid][$event->userid])) {
454 $processedevents[$event->contextid][$event->userid] = array();
369389c9
DM
455 }
456
413f19bc 457 // Contextid and userid have already been used to index the events, the next field to index by is eventname:
369389c9 458 // crud is unique per eventname, courseid is the same for all records and we append timecreated.
f67f35f3
DM
459 if (!isset($processedevents[$event->contextid][$event->userid][$event->eventname])) {
460
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;
369389c9 470 // We want timecreated attribute to be an array containing all user access times.
f67f35f3 471 $processedevents[$event->contextid][$event->userid][$event->eventname]->timecreated = array();
369389c9 472 }
f67f35f3
DM
473
474 // Add the event timecreated.
475 $processedevents[$event->contextid][$event->userid][$event->eventname]->timecreated[] = intval($event->timecreated);
369389c9 476 }
f67f35f3 477 $events->close();
369389c9 478
f67f35f3 479 return $processedevents;
369389c9
DM
480 }
481
482 /**
483 * Whether grades should be checked or not when looking for feedback.
484 *
413f19bc 485 * @return bool
369389c9
DM
486 */
487 protected function feedback_check_grades() {
488 return true;
489 }
022f3f07
AA
490
491 /**
1cc2b4ba 492 * Calculates the cognitive depth of a sample.
022f3f07 493 *
413f19bc
DM
494 * @param int $sampleid
495 * @param string $tablename
496 * @param int $starttime
497 * @param int $endtime
022f3f07
AA
498 * @return float|int|null
499 * @throws \coding_exception
500 */
501 protected function cognitive_calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) {
502
503 // May not be available.
504 $user = $this->retrieve('user', $sampleid);
505
506 if (!$useractivities = $this->get_student_activities($sampleid, $tablename, $starttime, $endtime)) {
507 // Null if no activities.
508 return null;
509 }
510
511 $scoreperactivity = (self::get_max_value() - self::get_min_value()) / count($useractivities);
512
513 $score = self::get_min_value();
514
515 // Iterate through the module activities/resources which due date is part of this time range.
516 foreach ($useractivities as $contextid => $cm) {
517
518 $potentiallevel = $this->get_cognitive_depth_level($cm);
edee629d 519 if (!is_int($potentiallevel) || $potentiallevel > self::MAX_COGNITIVE_LEVEL || $potentiallevel < 1) {
f67f35f3 520 throw new \coding_exception('Activities\' potential cognitive depth go from 1 to 5.');
022f3f07
AA
521 }
522 $scoreperlevel = $scoreperactivity / $potentiallevel;
523
524 switch ($potentiallevel) {
525 case 5:
3a396286 526 // Cognitive level 5 is to submit after feedback.
022f3f07
AA
527 if ($this->any_feedback('submitted', $cm, $contextid, $user)) {
528 $score += $scoreperlevel * 5;
529 break;
530 }
413f19bc 531 // The user didn't reach the activity max cognitive depth, continue with level 2.
022f3f07
AA
532
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 }
413f19bc 539 // The user didn't reach the activity max cognitive depth, continue with level 2.
022f3f07
AA
540
541 case 3:
542 // Cognitive level 3 is to view feedback.
543
544 if ($this->any_feedback('viewed', $cm, $contextid, $user)) {
545 // Max score for level 3.
546 $score += $scoreperlevel * 3;
547 break;
548 }
413f19bc 549 // The user didn't reach the activity max cognitive depth, continue with level 2.
022f3f07
AA
550
551 case 2:
552 // Cognitive depth level 2 is to submit content.
553
554 if ($this->any_write_log($contextid, $user)) {
555 $score += $scoreperlevel * 2;
556 break;
557 }
413f19bc 558 // The user didn't reach the activity max cognitive depth, continue with level 1.
022f3f07
AA
559
560 case 1:
561 // Cognitive depth level 1 is just accessing the activity.
562
563 if ($this->any_log($contextid, $user)) {
564 $score += $scoreperlevel;
565 }
566
567 default:
568 }
569 }
570
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 }
579
580 /**
1cc2b4ba 581 * Calculates the social breadth of a sample.
022f3f07 582 *
413f19bc
DM
583 * @param int $sampleid
584 * @param string $tablename
585 * @param int $starttime
586 * @param int $endtime
022f3f07
AA
587 * @return float|int|null
588 */
589 protected function social_calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) {
590
591 // May not be available.
592 $user = $this->retrieve('user', $sampleid);
593
594 if (!$useractivities = $this->get_student_activities($sampleid, $tablename, $starttime, $endtime)) {
595 // Null if no activities.
596 return null;
597 }
598
599 $scoreperactivity = (self::get_max_value() - self::get_min_value()) / count($useractivities);
600
601 $score = self::get_min_value();
602
603 foreach ($useractivities as $contextid => $cm) {
1eb6c52b
AA
604
605 $potentiallevel = $this->get_social_breadth_level($cm);
edee629d 606 if (!is_int($potentiallevel) || $potentiallevel > self::MAX_SOCIAL_LEVEL || $potentiallevel < 1) {
f07d93de
DM
607 throw new \coding_exception('Activities\' potential social breadth go from 1 to ' .
608 community_of_inquiry_activity::MAX_SOCIAL_LEVEL . '.');
022f3f07 609 }
1eb6c52b 610 $scoreperlevel = $scoreperactivity / $potentiallevel;
1eb6c52b
AA
611 switch ($potentiallevel) {
612 case 2:
f07d93de
DM
613 case 3:
614 case 4:
615 case 5:
616 // Core activities social breadth only reaches level 2, until core activities social
617 // breadth do not reach level 5 we limit it to what we currently support, which is level 2.
618
413f19bc 619 // Social breadth level 2 is to view feedback. (Same as cognitive level 3).
1eb6c52b
AA
620
621 if ($this->any_feedback('viewed', $cm, $contextid, $user)) {
622 // Max score for level 2.
623 $score += $scoreperlevel * 2;
624 break;
625 }
413f19bc
DM
626 // The user didn't reach the activity max social breadth, continue with level 1.
627
1eb6c52b
AA
628 case 1:
629 // Social breadth level 1 is just accessing the activity.
630 if ($this->any_log($contextid, $user)) {
631 $score += $scoreperlevel;
632 }
633 }
634
022f3f07
AA
635 }
636
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 }
645
646 /**
647 * calculate_sample
648 *
413f19bc 649 * @throws \coding_exception
022f3f07
AA
650 * @param int $sampleid
651 * @param string $tablename
413f19bc
DM
652 * @param int $starttime
653 * @param int $endtime
022f3f07 654 * @return float|int|null
022f3f07 655 */
a40952d3 656 protected function calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) {
022f3f07
AA
657 if ($this->get_indicator_type() == self::INDICATOR_COGNITIVE) {
658 return $this->cognitive_calculate_sample($sampleid, $tablename, $starttime, $endtime);
659 } else if ($this->get_indicator_type() == self::INDICATOR_SOCIAL) {
660 return $this->social_calculate_sample($sampleid, $tablename, $starttime, $endtime);
661 }
662 throw new \coding_exception("Indicator type is invalid.");
663 }
664
665 /**
666 * Defines indicator type.
667 *
413f19bc 668 * @return string
022f3f07 669 */
edee629d 670 abstract public function get_indicator_type();
369389c9 671}