diff --git a/analytics/classes/local/time_splitting/after_start.php b/analytics/classes/local/time_splitting/after_start.php new file mode 100644 index 0000000000000..ad3ebb8c926fb --- /dev/null +++ b/analytics/classes/local/time_splitting/after_start.php @@ -0,0 +1,111 @@ +. + +/** + * Time splitting method that generates predictions X days/weeks/months after the analysable start. + * + * @package core_analytics + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_analytics\local\time_splitting; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Time splitting method that generates predictions X days/weeks/months after the analysable start. + * + * @package core_analytics + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class after_start extends \core_analytics\local\time_splitting\base implements before_now { + + /** + * The period we should wait until we generate predictions for this. + * + * @param \core_analytics\analysable $analysable + * @return \DateInterval + */ + abstract protected function wait_period(\core_analytics\analysable $analysable); + + /** + * Returns whether the course can be processed by this time splitting method or not. + * + * @param \core_analytics\analysable $analysable + * @return bool + */ + public function is_valid_analysable(\core_analytics\analysable $analysable) { + + if (!$analysable->get_start()) { + return false; + } + + $predictionstart = $this->get_prediction_interval_start($analysable); + if ($analysable->get_start() > $predictionstart) { + // We still need to wait. + return false; + } + + return true; + } + + /** + * This time-splitting method returns one single range, the start to two days before the end. + * + * @return array The list of ranges, each of them including 'start', 'end' and 'time' + */ + protected function define_ranges() { + + $now = time(); + $ranges = [ + [ + 'start' => $this->analysable->get_start(), + 'end' => $now, + 'time' => $now, + ] + ]; + + return $ranges; + } + + /** + * Whether to cache or not the indicator calculations. + * + * @return bool + */ + public function cache_indicator_calculations(): bool { + return false; + } + + /** + * Calculates the interval start time backwards, from now. + * + * @param \core_analytics\analysable $analysable + * @return int + */ + protected function get_prediction_interval_start(\core_analytics\analysable $analysable) { + + // The prediction time is always time(). We don't want to reuse the firstanalysis time + // because otherwise samples (e.g. students) which start after the analysable (e.g. course) + // start would use an incorrect analysis interval. + $predictionstart = new \DateTime('now'); + $predictionstart->sub($this->wait_period($analysable)); + + return $predictionstart->getTimestamp(); + } +} diff --git a/analytics/classes/local/time_splitting/past_periodic.php b/analytics/classes/local/time_splitting/past_periodic.php new file mode 100644 index 0000000000000..ee7b7d82c3dc4 --- /dev/null +++ b/analytics/classes/local/time_splitting/past_periodic.php @@ -0,0 +1,97 @@ +. + +/** + * Time splitting method that generates predictions regularly. + * + * @package core_analytics + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_analytics\local\time_splitting; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Time splitting method that generates predictions periodically. + * + * @package core_analytics + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class past_periodic extends periodic implements before_now { + + /** + * Gets the next range with start on the provided time. + * + * The next range is based on the past period so we substract this + * range's periodicity from $time. + * + * @param \DateTimeImmutable $time + * @return array + */ + protected function get_next_range(\DateTimeImmutable $time) { + + $end = $time->getTimestamp(); + $start = $time->sub($this->periodicity())->getTimestamp(); + + if ($start < $this->analysable->get_start()) { + // We skip the first range generated as its start is prior to the analysable start. + return false; + } + + return [ + 'start' => $start, + 'end' => $end, + 'time' => $end + ]; + } + + /** + * Get the start of the first time range. + * + * @return int A timestamp. + */ + protected function get_first_start() { + return $this->analysable->get_start(); + } + + /** + * Guarantees that the last range dates end right now. + * + * @param array $ranges + * @return array + */ + protected function update_last_range(array $ranges) { + $lastrange = end($ranges); + + if ($lastrange['time'] > time()) { + // We just need to wait in this case. + return $lastrange; + } + + $timetoenddiff = time() - $lastrange['time']; + + $ranges[count($ranges) - 1] = [ + 'start' => $lastrange['start'] + $timetoenddiff, + 'end' => $lastrange['end'] + $timetoenddiff, + 'time' => $lastrange['time'] + $timetoenddiff, + ]; + + return $ranges; + } +} diff --git a/analytics/classes/local/time_splitting/periodic.php b/analytics/classes/local/time_splitting/periodic.php index 891820ea3c685..2fe0ef25f3efd 100644 --- a/analytics/classes/local/time_splitting/periodic.php +++ b/analytics/classes/local/time_splitting/periodic.php @@ -42,6 +42,21 @@ abstract class periodic extends base { */ abstract protected function periodicity(); + /** + * Gets the next range with start on the provided time. + * + * @param \DateTimeImmutable $time + * @return array + */ + abstract protected function get_next_range(\DateTimeImmutable $time); + + /** + * Get the start of the first time range. + * + * @return int A timestamp. + */ + abstract protected function get_first_start(); + /** * Returns whether the analysable can be processed by this time splitting method or not. * @@ -67,25 +82,42 @@ protected function define_ranges() { if ($this->analysable->get_end()) { $end = (new \DateTimeImmutable())->setTimestamp($this->analysable->get_end()); } - $next = (new \DateTimeImmutable())->setTimestamp($this->get_first_start()); + $nexttime = (new \DateTimeImmutable())->setTimestamp($this->get_first_start()); $now = new \DateTimeImmutable('now', \core_date::get_server_timezone_object()); - $ranges = []; - while ($next < $now && - (empty($end) || $next < $end)) { - $range = $this->get_next_range($next); - if ($range) { - $ranges[] = $range; + $range = $this->get_next_range($nexttime); + if (!$range) { + $nexttime = $nexttime->add($periodicity); + $range = $this->get_next_range($nexttime); + + if (!$range) { + throw new \coding_exception('The get_next_range implementation is broken. The difference between two consecutive + ranges can not be more than the periodicity.'); } - $next = $next->add($periodicity); } - $nextrange = $this->get_next_range($next); - if ($this->ready_to_predict($nextrange) && (empty($end) || $next < $end)) { - // Add the next one if we have not reached the analysable end yet. - // It will be used to get predictions. - $ranges[] = $nextrange; + $ranges = []; + $endreached = false; + while (($this->ready_to_predict($range) || $this->ready_to_train($range)) && !$endreached) { + $ranges[] = $range; + $nexttime = $nexttime->add($periodicity); + $range = $this->get_next_range($nexttime); + + $endreached = (!empty($end) && $nexttime > $end); + } + + if ($ranges && !$endreached) { + // If this analysable is not finished we adjust the start and end of the last element in $ranges + // so that it ends in time().The reason is that the start of these ranges is based on the analysable + // start and the end is calculated based on the start. This is to prevent the same issue we had in MDL-65348. + // + // An example of the situation we want to avoid is: + // A course started on a Monday, in 2015. It has no end date. Now the system is upgraded to Moodle 3.8, which + // includes this code. This happens on Wednesday. Periodic ranges (e.g. weekly) will be calculated from a Monday + // so the data provided by the time-splitting method would be from Monday to Monday, when we really want to + // provide data from Wednesday to the past Wednesday. + $ranges = $this->update_last_range($ranges); } return $ranges; @@ -119,34 +151,12 @@ public function get_training_ranges() { } /** - * The next range is based on the past period. + * Allows child classes to update the last range provided. * - * @param \DateTimeImmutable $next + * @param array $ranges * @return array */ - protected function get_next_range(\DateTimeImmutable $next) { - - $end = $next->getTimestamp(); - $start = $next->sub($this->periodicity())->getTimestamp(); - - if ($start < $this->analysable->get_start()) { - // We skip the first range generated as its start is prior to the analysable start. - return false; - } - - return [ - 'start' => $start, - 'end' => $end, - 'time' => $end - ]; - } - - /** - * Get the start of the first time range. - * - * @return int A timestamp. - */ - protected function get_first_start() { - return $this->analysable->get_start(); + protected function update_last_range(array $ranges) { + return $ranges; } } diff --git a/analytics/classes/local/time_splitting/upcoming_periodic.php b/analytics/classes/local/time_splitting/upcoming_periodic.php index 7b3c9c6571a14..9d4e1dabd31ea 100644 --- a/analytics/classes/local/time_splitting/upcoming_periodic.php +++ b/analytics/classes/local/time_splitting/upcoming_periodic.php @@ -36,15 +36,18 @@ abstract class upcoming_periodic extends periodic implements after_now { /** - * The next range indicator calculations should be based on upcoming dates. + * Gets the next range with start on the provided time. * - * @param \DateTimeImmutable $next + * The next range is based on the upcoming period so we add this + * range's periodicity to $time. + * + * @param \DateTimeImmutable $time * @return array */ - protected function get_next_range(\DateTimeImmutable $next) { + protected function get_next_range(\DateTimeImmutable $time) { - $start = $next->getTimestamp(); - $end = $next->add($this->periodicity())->getTimestamp(); + $start = $time->getTimestamp(); + $end = $time->add($this->periodicity())->getTimestamp(); return [ 'start' => $start, 'end' => $end, @@ -87,7 +90,7 @@ protected function get_first_start() { return $firstanalysis; } - // This analysable has not yet been analysed, the start is therefore now (-1 so ready_to_predict can be executed). - return time() - 1; + // This analysable has not yet been analysed, the start is therefore now. + return time(); } } diff --git a/analytics/tests/fixtures/test_timesplitting_seconds.php b/analytics/tests/fixtures/test_timesplitting_seconds.php index e4b4f70423c67..289474bcc2049 100644 --- a/analytics/tests/fixtures/test_timesplitting_seconds.php +++ b/analytics/tests/fixtures/test_timesplitting_seconds.php @@ -31,7 +31,7 @@ * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class test_timesplitting_seconds extends \core_analytics\local\time_splitting\periodic { +class test_timesplitting_seconds extends \core_analytics\local\time_splitting\past_periodic { /** * Every second. diff --git a/analytics/tests/manager_test.php b/analytics/tests/manager_test.php index 74e67216b636b..c092491f0ffa0 100644 --- a/analytics/tests/manager_test.php +++ b/analytics/tests/manager_test.php @@ -365,10 +365,14 @@ public function test_update_default_models_for_component() { $noteaching = \core_analytics\manager::get_target('\core_course\analytics\target\no_teaching'); $dropout = \core_analytics\manager::get_target('\core_course\analytics\target\course_dropout'); $upcomingactivities = \core_analytics\manager::get_target('\core_user\analytics\target\upcoming_activities_due'); + $norecentaccesses = \core_analytics\manager::get_target('\core_course\analytics\target\no_recent_accesses'); + $noaccesssincestart = \core_analytics\manager::get_target('\core_course\analytics\target\no_access_since_course_start'); $this->assertTrue(\core_analytics\model::exists($noteaching)); $this->assertTrue(\core_analytics\model::exists($dropout)); $this->assertTrue(\core_analytics\model::exists($upcomingactivities)); + $this->assertTrue(\core_analytics\model::exists($norecentaccesses)); + $this->assertTrue(\core_analytics\model::exists($noaccesssincestart)); foreach (\core_analytics\manager::get_all_models() as $model) { $model->delete(); @@ -377,16 +381,22 @@ public function test_update_default_models_for_component() { $this->assertFalse(\core_analytics\model::exists($noteaching)); $this->assertFalse(\core_analytics\model::exists($dropout)); $this->assertFalse(\core_analytics\model::exists($upcomingactivities)); + $this->assertFalse(\core_analytics\model::exists($norecentaccesses)); + $this->assertFalse(\core_analytics\model::exists($noaccesssincestart)); $updated = \core_analytics\manager::update_default_models_for_component('moodle'); - $this->assertEquals(3, count($updated)); + $this->assertEquals(5, count($updated)); + $this->assertTrue(array_pop($updated) instanceof \core_analytics\model); + $this->assertTrue(array_pop($updated) instanceof \core_analytics\model); $this->assertTrue(array_pop($updated) instanceof \core_analytics\model); $this->assertTrue(array_pop($updated) instanceof \core_analytics\model); $this->assertTrue(array_pop($updated) instanceof \core_analytics\model); $this->assertTrue(\core_analytics\model::exists($noteaching)); $this->assertTrue(\core_analytics\model::exists($dropout)); $this->assertTrue(\core_analytics\model::exists($upcomingactivities)); + $this->assertTrue(\core_analytics\model::exists($norecentaccesses)); + $this->assertTrue(\core_analytics\model::exists($noaccesssincestart)); $repeated = \core_analytics\manager::update_default_models_for_component('moodle'); diff --git a/analytics/tests/stats_test.php b/analytics/tests/stats_test.php index 01bc9fda5fced..d543fa1c4a13b 100644 --- a/analytics/tests/stats_test.php +++ b/analytics/tests/stats_test.php @@ -53,7 +53,7 @@ public function test_enabled_models() { // By default, sites have {@link \core_course\analytics\target\no_teaching} and // {@link \core_user\analytics\target\upcoming_activities_due} enabled. - $this->assertEquals(2, \core_analytics\stats::enabled_models()); + $this->assertEquals(4, \core_analytics\stats::enabled_models()); $model = \core_analytics\model::create( \core_analytics\manager::get_target('\core_course\analytics\target\course_dropout'), @@ -63,11 +63,11 @@ public function test_enabled_models() { ); // Purely adding a new model does not make it included in the stats. - $this->assertEquals(2, \core_analytics\stats::enabled_models()); + $this->assertEquals(4, \core_analytics\stats::enabled_models()); // New models must be enabled to have them counted. $model->enable('\core\analytics\time_splitting\quarters'); - $this->assertEquals(3, \core_analytics\stats::enabled_models()); + $this->assertEquals(5, \core_analytics\stats::enabled_models()); } /** diff --git a/analytics/upgrade.txt b/analytics/upgrade.txt index 774b4fcc06be4..adb4b9cb7d436 100644 --- a/analytics/upgrade.txt +++ b/analytics/upgrade.txt @@ -12,6 +12,9 @@ information provided here is intended especially for developers. * Indicators can add information about calculated values by calling add_shared_calculation_info(). This data is later available for targets in get_insight_body_for_prediction(), it can be accessed appending ':extradata' to the indicator name (e.g. $sampledata['\mod_yeah\analytics\indicator\ou:extradata') +* A new \core_analytics\local\time_splitting\past_periodic abstract class has been added. Time-splitting + methods extending \core_analytics\local\time_splitting\periodic directly should be extending past_periodic + now. 'periodic' can still be directly extended by implementing get_next_range and get_first_start methods. === 3.7 === diff --git a/course/classes/analytics/target/no_access_since_course_start.php b/course/classes/analytics/target/no_access_since_course_start.php new file mode 100644 index 0000000000000..32c12af84bc4d --- /dev/null +++ b/course/classes/analytics/target/no_access_since_course_start.php @@ -0,0 +1,80 @@ +. + +/** + * No accesses since the start of the course. + * + * @package core_course + * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_course\analytics\target; + +defined('MOODLE_INTERNAL') || die(); + +/** + * No accesses since the start of the course. + * + * @package core_course + * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class no_access_since_course_start extends no_recent_accesses { + + /** + * Only past stuff whose start matches the course start. + * + * @param \core_analytics\local\time_splitting\base $timesplitting + * @return bool + */ + public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool { + return ($timesplitting instanceof \core_analytics\local\time_splitting\after_start); + } + + /** + * Returns the name. + * + * If there is a corresponding '_help' string this will be shown as well. + * + * @return \lang_string + */ + public static function get_name() : \lang_string { + return new \lang_string('target:noaccesssincecoursestart', 'course'); + } + + /** + * Returns the body message for the insight. + * + * @param \context $context + * @param string $contextname + * @param \stdClass $user + * @param \moodle_url $insighturl + * @return array The plain text message and the HTML message + */ + public function get_insight_body(\context $context, string $contextname, \stdClass $user, \moodle_url $insighturl): array { + global $OUTPUT; + + $a = (object)['coursename' => $contextname, 'userfirstname' => $user->firstname]; + $fullmessage = get_string('noaccesssincestartinfomessage', 'course', $a) . PHP_EOL . PHP_EOL . $insighturl->out(false); + $fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message', + ['url' => $insighturl->out(false), 'insightinfomessage' => get_string('noaccesssincestartinfomessage', 'course', $a)] + ); + + return [$fullmessage, $fullmessagehtml]; + } + +} diff --git a/course/classes/analytics/target/no_recent_accesses.php b/course/classes/analytics/target/no_recent_accesses.php new file mode 100644 index 0000000000000..4f7626b74a350 --- /dev/null +++ b/course/classes/analytics/target/no_recent_accesses.php @@ -0,0 +1,135 @@ +. + +/** + * No recent accesses. + * + * @package core_course + * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_course\analytics\target; + +defined('MOODLE_INTERNAL') || die(); + +/** + * No recent accesses. + * + * @package core_course + * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class no_recent_accesses extends course_enrolments { + + /** + * Machine learning backends are not required to predict. + * + * @return bool + */ + public static function based_on_assumptions() { + return true; + } + + /** + * Returns the name. + * + * If there is a corresponding '_help' string this will be shown as well. + * + * @return \lang_string + */ + public static function get_name() : \lang_string { + return new \lang_string('target:norecentaccesses', 'course'); + } + + /** + * Returns the body message for the insight. + * + * @param \context $context + * @param string $contextname + * @param \stdClass $user + * @param \moodle_url $insighturl + * @return array The plain text message and the HTML message + */ + public function get_insight_body(\context $context, string $contextname, \stdClass $user, \moodle_url $insighturl): array { + global $OUTPUT; + + $a = (object)['coursename' => $contextname, 'userfirstname' => $user->firstname]; + $fullmessage = get_string('norecentaccessesinfomessage', 'course', $a) . PHP_EOL . PHP_EOL . $insighturl->out(false); + $fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message', + ['url' => $insighturl->out(false), 'insightinfomessage' => get_string('norecentaccessesinfomessage', 'course', $a)] + ); + + return [$fullmessage, $fullmessagehtml]; + } + + /** + * Only past stuff whose start matches the course start. + * + * @param \core_analytics\local\time_splitting\base $timesplitting + * @return bool + */ + public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool { + return ($timesplitting instanceof \core_analytics\local\time_splitting\past_periodic); + } + + /** + * Discards courses that are not yet ready to be used for prediction. + * + * @param \core_analytics\analysable $course + * @param bool $fortraining + * @return true|string + */ + public function is_valid_analysable(\core_analytics\analysable $course, $fortraining = true) { + + if (!$course->was_started()) { + return get_string('coursenotyetstarted', 'course'); + } + + if (!$this->students = $course->get_students()) { + return get_string('nocoursestudents', 'course'); + } + + if ($course->get_end() && $course->get_end() < $course->get_start()) { + return get_string('errorendbeforestart', 'course'); + } + + // Finished courses can not be used to get predictions. + if (!$fortraining && $course->is_finished()) { + return get_string('coursealreadyfinished', 'course'); + } + + return true; + } + + /** + * Do the user has any read action in the course? + * + * @param int $sampleid + * @param \core_analytics\analysable $analysable + * @param int $starttime + * @param int $endtime + * @return float 0 -> accesses, 1 -> no accesses. + */ + protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) { + + $readactions = $this->retrieve('\core\analytics\indicator\any_course_access', $sampleid); + if ($readactions == \core\analytics\indicator\any_course_access::get_min_value()) { + return 1; + } + return 0; + } +} diff --git a/lang/en/course.php b/lang/en/course.php index b73573555578b..636cf13050e5c 100644 --- a/lang/en/course.php +++ b/lang/en/course.php @@ -46,6 +46,12 @@ $string['nocourseendtime'] = 'The course does not have an end time'; $string['nocoursesections'] = 'No course sections'; $string['nocoursestudents'] = 'No students'; +$string['noaccesssincestartinfomessage'] = 'Hi {$a->userfirstname}, + +

Students in {$a->coursename} have never accessed the course.'; +$string['norecentaccessesinfomessage'] = 'Hi {$a->userfirstname}, + +

Students in {$a->coursename} have not accessed the course recently.'; $string['noteachinginfomessage'] = 'Hi {$a->userfirstname},

Courses with start dates in the next week have been identified as having no teacher or student enrolments.'; @@ -66,6 +72,10 @@ $string['target:coursedropout_help'] = 'This target describes whether the student is considered at risk of dropping out.'; $string['target:coursegradetopass'] = 'Students at risk of not achieving the minimum grade to pass the course'; $string['target:coursegradetopass_help'] = 'This target describes whether the student is at risk of not achieving the minimum grade to pass the course.'; +$string['target:noaccesssincecoursestart'] = 'Students who have not accessed the course yet'; +$string['target:noaccesssincecoursestart_help'] = 'This target describes students who never accessed a course they are enrolled in.'; +$string['target:norecentaccesses'] = 'Students who have not accessed the course recently'; +$string['target:norecentaccesses_help'] = 'This target describes students who have not accessed a course recently.'; $string['target:noteachingactivity'] = 'Courses at risk of not starting'; $string['target:noteachingactivity_help'] = 'This target describes whether courses due to start in the coming week will have teaching activity.'; $string['targetlabelstudentcompletionno'] = 'Student who is likely to meet the course completion conditions'; diff --git a/lang/en/moodle.php b/lang/en/moodle.php index 58180eecccafe..48edfc5d208c9 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -1046,6 +1046,8 @@ $string['indicator:accessesbeforestart_help'] = 'This indicator reflects if the student accessed the course before the course start date.'; $string['indicator:activitiesdue'] = 'Activities due'; $string['indicator:activitiesdue_help'] = 'The user has activities due.'; +$string['indicator:anycourseaccess'] = 'Any course access'; +$string['indicator:anycourseaccess_help'] = 'This indicator reflects any accesses to the provided course for the provided user.'; $string['indicator:anywrite'] = 'Any write action'; $string['indicator:anywrite_help'] = 'This indicator represents any write (submit) action taken by the student.'; $string['indicator:anywriteincourse'] = 'Any write action in the course'; @@ -1991,12 +1993,24 @@ $string['timesplitting:quartersaccum_help'] = 'This analysis interval divides the course into quarters (4 equal parts), with each prediction being based on the accumulated data of all previous quarters.'; $string['timesplitting:singlerange'] = 'From start to end'; $string['timesplitting:singlerange_help'] = 'This analysis interval considers the entire course as a single span.'; +$string['timesplitting:onemonthafterstart'] = 'One month after start'; +$string['timesplitting:onemonthafterstart_help'] = 'This analysis interval generates a prediction 1 month after the analysable start.'; +$string['timesplitting:oneweekafterstart'] = 'One week after start'; +$string['timesplitting:oneweekafterstart_help'] = 'This analysis interval generates a prediction 1 week after the analysable start.'; +$string['timesplitting:past3days'] = 'Past 3 days'; +$string['timesplitting:past3days_help'] = 'This analysis interval generates predictions every 3 days. The indicators calculations will be based on the past 3 days.'; +$string['timesplitting:pastmonth'] = 'Past month'; +$string['timesplitting:pastmonth_help'] = 'This analysis interval generates predictions every month. The indicators calculations will be based on the past month.'; +$string['timesplitting:pastweek'] = 'Past week'; +$string['timesplitting:pastweek_help'] = 'This analysis interval generates predictions every week. The indicators calculations will be based on the past week.'; $string['timesplitting:upcoming3days'] = 'Upcoming 3 days'; $string['timesplitting:upcoming3days_help'] = 'This analysis interval generates predictions every 3 days. The indicators calculations will be based on the upcoming 3 days.'; $string['timesplitting:upcomingfortnight'] = 'Upcoming fortnight'; $string['timesplitting:upcomingfortnight_help'] = 'This analysis interval generates predictions every fortnight. The indicators calculations will be based on the upcoming fortnight.'; $string['timesplitting:upcomingweek'] = 'Upcoming week'; $string['timesplitting:upcomingweek_help'] = 'This analysis interval generates predictions every week. The indicators calculations will be based on the upcoming week.'; +$string['timesplitting:tenpercentafterstart'] = '10% after start'; +$string['timesplitting:tenpercentafterstart_help'] = 'This analysis interval generates a prediction after the 10% of the course is completed.'; $string['thanks'] = 'Thanks'; $string['theme'] = 'Theme'; $string['themes'] = 'Themes'; diff --git a/lib/classes/analytics/indicator/any_course_access.php b/lib/classes/analytics/indicator/any_course_access.php new file mode 100644 index 0000000000000..355e382dc0275 --- /dev/null +++ b/lib/classes/analytics/indicator/any_course_access.php @@ -0,0 +1,135 @@ +. + +/** + * Any access indicator. + * + * @package core + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\analytics\indicator; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Any access indicator. + * + * @package core + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class any_course_access extends \core_analytics\local\indicator\binary { + + /** + * Returns the name. + * + * If there is a corresponding '_help' string this will be shown as well. + * + * @return \lang_string + */ + public static function get_name() : \lang_string { + return new \lang_string('indicator:anycourseaccess'); + } + + /** + * required_sample_data + * + * @return string[] + */ + public static function required_sample_data() { + return array('course', 'user'); + } + + /** + * Store userid => timeaccess relation if the provided analysable is a course. + * + * @param \core_analytics\analysable $analysable + * @return null + */ + public function fill_per_analysable_caches(\core_analytics\analysable $analysable) { + global $DB; + + if ($analysable instanceof \core_analytics\course) { + // Indexed by userid (there is a UNIQUE KEY at DB level). + $this->lastaccesses = $DB->get_records('user_lastaccess', ['courseid' => $analysable->get_id()], + '', 'userid, timeaccess'); + } + } + + /** + * calculate_sample + * + * @param int $sampleid + * @param string $sampleorigin + * @param int $starttime + * @param int $endtime + * @return float + */ + protected function calculate_sample($sampleid, $sampleorigin, $starttime = false, $endtime = false) { + + $course = $this->retrieve('course', $sampleid); + $user = $this->retrieve('user', $sampleid); + + // We first try using user_lastaccess as it is much faster than the log table. + if (empty($this->lastaccesses[$user->id]->timeaccess)) { + // The user never accessed. + return self::get_min_value(); + } else if (!$starttime && !$endtime) { + // No time restrictions, so all good as long as there is a record. + return self::get_max_value(); + } else if ($starttime && $this->lastaccesses[$user->id]->timeaccess < $starttime) { + // The last access is prior to $starttime. + return self::get_min_value(); + } else if ($endtime && $this->lastaccesses[$user->id]->timeaccess < $endtime) { + // The last access is before the $endtime. + return self::get_max_value(); + } else if ($starttime && !$endtime && $starttime <= $this->lastaccesses[$user->id]->timeaccess) { + // No end time, so max value as long as the last access is after $starttime. + return self::get_max_value(); + } + + // If the last access is after $endtime we can not know for sure if the user accessed or not + // between $starttime and $endtime, we need to check the logs table in this case. Note that + // it is unlikely that we will reach this point as this indicator will be used in models whose + // dates are in the past. + + if (!$logstore = \core_analytics\manager::get_analytics_logstore()) { + throw new \coding_exception('No available log stores'); + } + + // Filter by context to use the logstore_standard_log db table index. + $select = "userid = :userid AND courseid = :courseid"; + $params = ['courseid' => $course->id, 'userid' => $user->id]; + + if ($starttime) { + $select .= " AND timecreated > :starttime"; + $params['starttime'] = $starttime; + } + if ($endtime) { + $select .= " AND timecreated <= :endtime"; + $params['endtime'] = $endtime; + } + + $nlogs = $logstore->get_events_select_count($select, $params); + if ($nlogs) { + return self::get_max_value(); + } else { + return self::get_min_value(); + } + } +} diff --git a/lib/classes/analytics/time_splitting/one_month_after_start.php b/lib/classes/analytics/time_splitting/one_month_after_start.php new file mode 100644 index 0000000000000..650c176ab1384 --- /dev/null +++ b/lib/classes/analytics/time_splitting/one_month_after_start.php @@ -0,0 +1,56 @@ +. + +/** + * Time splitting method that generates predictions one month after the analysable start. + * + * @package core_analytics + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\analytics\time_splitting; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Time splitting method that generates predictions one month after the analysable start. + * + * @package core_analytics + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class one_month_after_start extends \core_analytics\local\time_splitting\after_start { + + /** + * The time splitting method name. + * + * @return \lang_string + */ + public static function get_name() : \lang_string { + return new \lang_string('timesplitting:onemonthafterstart'); + } + + /** + * The period we should wait until we generate predictions for this. + * + * @param \core_analytics\analysable $analysable Not used in this implementation. + * @return \DateInterval + */ + protected function wait_period(\core_analytics\analysable $analysable) { + return new \DateInterval('P1M'); + } +} diff --git a/lib/classes/analytics/time_splitting/one_week_after_start.php b/lib/classes/analytics/time_splitting/one_week_after_start.php new file mode 100644 index 0000000000000..595748488b0f2 --- /dev/null +++ b/lib/classes/analytics/time_splitting/one_week_after_start.php @@ -0,0 +1,56 @@ +. + +/** + * Time splitting method that generates predictions one week after the analysable start. + * + * @package core_analytics + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\analytics\time_splitting; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Time splitting method that generates predictions one week after the analysable start. + * + * @package core_analytics + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class one_week_after_start extends \core_analytics\local\time_splitting\after_start { + + /** + * The time splitting method name. + * + * @return \lang_string + */ + public static function get_name() : \lang_string { + return new \lang_string('timesplitting:oneweekafterstart'); + } + + /** + * The period we should wait until we generate predictions for this. + * + * @param \core_analytics\analysable $analysable Not used in this implementation. + * @return \DateInterval + */ + protected function wait_period(\core_analytics\analysable $analysable) { + return new \DateInterval('P1W'); + } +} diff --git a/lib/classes/analytics/time_splitting/past_3_days.php b/lib/classes/analytics/time_splitting/past_3_days.php new file mode 100644 index 0000000000000..a63a38a1417ff --- /dev/null +++ b/lib/classes/analytics/time_splitting/past_3_days.php @@ -0,0 +1,55 @@ +. + +/** + * Time splitting method that generates predictions every 3 days. + * + * @package core_analytics + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\analytics\time_splitting; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Time splitting method that generates predictions every 3 days. + * + * @package core_analytics + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class past_3_days extends \core_analytics\local\time_splitting\past_periodic { + + /** + * The time splitting method name. + * + * @return \lang_string + */ + public static function get_name() : \lang_string { + return new \lang_string('timesplitting:past3days'); + } + + /** + * Once every 3 days. + * + * @return \DateInterval + */ + public function periodicity() { + return new \DateInterval('P3D'); + } +} diff --git a/lib/classes/analytics/time_splitting/past_month.php b/lib/classes/analytics/time_splitting/past_month.php new file mode 100644 index 0000000000000..3d73569eaf5a6 --- /dev/null +++ b/lib/classes/analytics/time_splitting/past_month.php @@ -0,0 +1,55 @@ +. + +/** + * Time splitting method that generates monthly predictions. + * + * @package core_analytics + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\analytics\time_splitting; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Time splitting method that generates monthly predictions. + * + * @package core_analytics + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class past_month extends \core_analytics\local\time_splitting\past_periodic { + + /** + * The time splitting method name. + * + * @return \lang_string + */ + public static function get_name() : \lang_string { + return new \lang_string('timesplitting:pastmonth'); + } + + /** + * Once a month. + * + * @return \DateInterval + */ + public function periodicity() { + return new \DateInterval('P1M'); + } +} diff --git a/analytics/tests/fixtures/test_timesplitting_weekly.php b/lib/classes/analytics/time_splitting/past_week.php similarity index 89% rename from analytics/tests/fixtures/test_timesplitting_weekly.php rename to lib/classes/analytics/time_splitting/past_week.php index 72403de9e0f87..438e5ac0e0a60 100644 --- a/analytics/tests/fixtures/test_timesplitting_weekly.php +++ b/lib/classes/analytics/time_splitting/past_week.php @@ -22,6 +22,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +namespace core\analytics\time_splitting; + defined('MOODLE_INTERNAL') || die(); /** @@ -31,18 +33,19 @@ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class test_timesplitting_weekly extends \core_analytics\local\time_splitting\periodic { +class past_week extends \core_analytics\local\time_splitting\past_periodic { /** * The time splitting method name. * @return \lang_string */ public static function get_name() : \lang_string { - return new \lang_string('error'); + return new \lang_string('timesplitting:pastweek'); } /** * Once per week. + * * @return \DateInterval */ public function periodicity() { diff --git a/lib/classes/analytics/time_splitting/ten_percent_after_start.php b/lib/classes/analytics/time_splitting/ten_percent_after_start.php new file mode 100644 index 0000000000000..a8b5642a6f41a --- /dev/null +++ b/lib/classes/analytics/time_splitting/ten_percent_after_start.php @@ -0,0 +1,81 @@ +. + +/** + * Time splitting method that generates predictions 3 days after the analysable start. + * + * @package core_analytics + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\analytics\time_splitting; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Time splitting method that generates predictions 3 days after the analysable start. + * + * @package core_analytics + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class ten_percent_after_start extends \core_analytics\local\time_splitting\after_start { + + /** + * The time splitting method name. + * + * @return \lang_string + */ + public static function get_name() : \lang_string { + return new \lang_string('timesplitting:tenpercentafterstart'); + } + + /** + * Extended as we require and end date here. + * + * @param \core_analytics\analysable $analysable + * @return bool + */ + public function is_valid_analysable(\core_analytics\analysable $analysable) { + + // We require an end date to calculate the 10%. + if (!$analysable->get_end()) { + return false; + } + + return parent::is_valid_analysable($analysable); + } + + /** + * The period we should wait until we generate predictions for this. + * + * @throws \coding_exception + * @param \core_analytics\analysable $analysable + * @return \DateInterval + */ + protected function wait_period(\core_analytics\analysable $analysable) { + + if (!$analysable->get_end() || !$analysable->get_start()) { + throw new \coding_exception('Analysables with no start or end should be discarded in is_valid_analysable.'); + } + + $diff = $analysable->get_end() - $analysable->get_start(); + + // A 10% of $diff. + return new \DateInterval('PT' . intval($diff / 10) . 'S'); + } +} diff --git a/lib/db/analytics.php b/lib/db/analytics.php index 5a689fcb250a9..7efac78d69456 100644 --- a/lib/db/analytics.php +++ b/lib/db/analytics.php @@ -97,4 +97,20 @@ 'timesplitting' => '\core\analytics\time_splitting\upcoming_week', 'enabled' => true, ], + [ + 'target' => '\core_course\analytics\target\no_access_since_course_start', + 'indicators' => [ + '\core\analytics\indicator\any_course_access', + ], + 'timesplitting' => '\core\analytics\time_splitting\one_month_after_start', + 'enabled' => true, + ], + [ + 'target' => '\core_course\analytics\target\no_recent_accesses', + 'indicators' => [ + '\core\analytics\indicator\any_course_access', + ], + 'timesplitting' => '\core\analytics\time_splitting\past_month', + 'enabled' => true, + ], ]; diff --git a/lib/tests/time_splittings_test.php b/lib/tests/time_splittings_test.php index 6394a3ed51c17..6587a70269b97 100644 --- a/lib/tests/time_splittings_test.php +++ b/lib/tests/time_splittings_test.php @@ -27,7 +27,6 @@ require_once(__DIR__ . '/../../analytics/tests/fixtures/test_timesplitting_seconds.php'); require_once(__DIR__ . '/../../analytics/tests/fixtures/test_timesplitting_upcoming_seconds.php'); -require_once(__DIR__ . '/../../analytics/tests/fixtures/test_timesplitting_weekly.php'); require_once(__DIR__ . '/../../lib/enrollib.php'); /** @@ -197,23 +196,38 @@ public function test_periodic() { // Using a finished course. - $weekly = new test_timesplitting_weekly(); - $weekly->set_analysable($this->analysable); - $this->assertCount(1, $weekly->get_distinct_ranges()); + $pastweek = new \core\analytics\time_splitting\past_week(); + $pastweek->set_analysable($this->analysable); + $this->assertCount(1, $pastweek->get_distinct_ranges()); - $ranges = $weekly->get_all_ranges(); + $ranges = $pastweek->get_all_ranges(); $this->assertEquals(52, count($ranges)); $this->assertEquals($this->course->startdate, $ranges[0]['start']); $this->assertNotEquals($this->course->startdate, $ranges[0]['time']); // The analysable is finished so all ranges are available for training. - $this->assertCount(count($ranges), $weekly->get_training_ranges()); + $this->assertCount(count($ranges), $pastweek->get_training_ranges()); - $ranges = $weekly->get_most_recent_prediction_range(); + $ranges = $pastweek->get_most_recent_prediction_range(); $range = reset($ranges); $this->assertEquals(51, key($ranges)); - // We now use an ongoing course. + // We now use an ongoing course not yet ready to generate predictions. + + $threedaysago = new DateTime('-3 days'); + $params = array( + 'startdate' => $threedaysago->getTimestamp(), + ); + $ongoingcourse = $this->getDataGenerator()->create_course($params); + $ongoinganalysable = new \core_analytics\course($ongoingcourse); + + $pastweek = new \core\analytics\time_splitting\past_week(); + $pastweek->set_analysable($ongoinganalysable); + $ranges = $pastweek->get_all_ranges(); + $this->assertEquals(0, count($ranges)); + $this->assertCount(0, $pastweek->get_training_ranges()); + + // We now use a ready-to-predict ongoing course. $onemonthago = new DateTime('-30 days'); $params = array( @@ -222,20 +236,24 @@ public function test_periodic() { $ongoingcourse = $this->getDataGenerator()->create_course($params); $ongoinganalysable = new \core_analytics\course($ongoingcourse); - $weekly = new test_timesplitting_weekly(); - $weekly->set_analysable($ongoinganalysable); - $this->assertCount(1, $weekly->get_distinct_ranges()); + $pastweek = new \core\analytics\time_splitting\past_week(); + $pastweek->set_analysable($ongoinganalysable); + $this->assertCount(1, $pastweek->get_distinct_ranges()); - $ranges = $weekly->get_all_ranges(); + $ranges = $pastweek->get_all_ranges(); $this->assertEquals(4, count($ranges)); - $this->assertCount(4, $weekly->get_training_ranges()); + $this->assertCount(4, $pastweek->get_training_ranges()); - $ranges = $weekly->get_most_recent_prediction_range(); + $ranges = $pastweek->get_most_recent_prediction_range(); $range = reset($ranges); $this->assertEquals(3, key($ranges)); - $this->assertLessThan(time(), $range['time']); - $this->assertLessThan(time(), $range['start']); - $this->assertLessThan(time(), $range['end']); + $this->assertEquals(time(), $range['time'], '', 1); + // 1 second delta for the start just in case a second passes between the set_analysable call + // and this checking below. + $time = new \DateTime(); + $time->sub($pastweek->periodicity()); + $this->assertEquals($time->getTimestamp(), $range['start'], '', 1.0); + $this->assertEquals(time(), $range['end'], '', 1); $starttime = time(); @@ -246,8 +264,8 @@ public function test_periodic() { $ranges = $upcomingweek->get_all_ranges(); $this->assertEquals(1, count($ranges)); $range = reset($ranges); - $this->assertLessThan(time(), $range['time']); - $this->assertLessThan(time(), $range['start']); + $this->assertEquals(time(), $range['time'], '', 1); + $this->assertEquals(time(), $range['start'], '', 1); $this->assertGreaterThan(time(), $range['end']); $this->assertCount(0, $upcomingweek->get_training_ranges()); @@ -255,12 +273,10 @@ public function test_periodic() { $ranges = $upcomingweek->get_most_recent_prediction_range(); $range = reset($ranges); $this->assertEquals(0, key($ranges)); - $this->assertLessThan(time(), $range['time']); - $this->assertLessThan(time(), $range['start']); - // We substract 1 because upcoming_periodic also has that -1 so that predictions - // get executed once the first time range is set. - $this->assertGreaterThanOrEqual($starttime - 1, $range['time']); - $this->assertGreaterThanOrEqual($starttime - 1, $range['start']); + $this->assertEquals(time(), $range['time'], '', 1); + $this->assertEquals(time(), $range['start'], '', 1); + $this->assertGreaterThanOrEqual($starttime, $range['time']); + $this->assertGreaterThanOrEqual($starttime, $range['start']); $this->assertGreaterThan(time(), $range['end']); $this->assertNotEmpty($upcomingweek->get_range_by_index(0)); @@ -280,7 +296,8 @@ public function test_periodic() { $seconds->set_analysable($analysable); // Store the ranges we just obtained. - $nranges = count($seconds->get_all_ranges()); + $ranges = $seconds->get_all_ranges(); + $nranges = count($ranges); $ntrainingranges = count($seconds->get_training_ranges()); $mostrecentrange = $seconds->get_most_recent_prediction_range(); $mostrecentrange = reset($mostrecentrange); @@ -291,7 +308,8 @@ public function test_periodic() { // We set the analysable again so the time ranges are recalculated. $seconds->set_analysable($analysable); - $nnewranges = $seconds->get_all_ranges(); + $newranges = $seconds->get_all_ranges(); + $nnewranges = count($newranges); $nnewtrainingranges = $seconds->get_training_ranges(); $newmostrecentrange = $seconds->get_most_recent_prediction_range(); $newmostrecentrange = reset($newmostrecentrange); @@ -299,25 +317,67 @@ public function test_periodic() { $this->assertGreaterThan($ntrainingranges, $nnewtrainingranges); $this->assertGreaterThan($mostrecentrange['time'], $newmostrecentrange['time']); - $seconds = new test_timesplitting_upcoming_seconds(); - $seconds->set_analysable($analysable); + // All the ranges but the last one should return the same values. + array_pop($ranges); + array_pop($newranges); + foreach ($ranges as $key => $range) { + $this->assertEquals($newranges[$key]['start'], $range['start']); + $this->assertEquals($newranges[$key]['end'], $range['end']); + $this->assertEquals($newranges[$key]['time'], $range['time']); + } + + // Fake model id, we can use any int, we will need to reference it later. + $modelid = 1505347200; + + $upcomingseconds = new test_timesplitting_upcoming_seconds(); + $upcomingseconds->set_modelid($modelid); + $upcomingseconds->set_analysable($analysable); // Store the ranges we just obtained. - $nranges = count($seconds->get_all_ranges()); - $ntrainingranges = count($seconds->get_training_ranges()); - $mostrecentrange = $seconds->get_most_recent_prediction_range(); + $ranges = $upcomingseconds->get_all_ranges(); + $nranges = count($ranges); + $ntrainingranges = count($upcomingseconds->get_training_ranges()); + $mostrecentrange = $upcomingseconds->get_most_recent_prediction_range(); $mostrecentrange = reset($mostrecentrange); + // Mimic the modelfirstanalyses caching in \core_analytics\analysis. + $this->mock_cache_first_analysis_caching($modelid, $analysable->get_id(), end($ranges)); + // We wait for the next range to be added. usleep(1000000); - $seconds->set_analysable($analysable); - $nnewranges = $seconds->get_all_ranges(); - $nnewtrainingranges = $seconds->get_training_ranges(); - $newmostrecentrange = $seconds->get_most_recent_prediction_range(); + // We set the analysable again so the time ranges are recalculated. + $upcomingseconds->set_analysable($analysable); + + $newranges = $upcomingseconds->get_all_ranges(); + $nnewranges = count($newranges); + $nnewtrainingranges = $upcomingseconds->get_training_ranges(); + $newmostrecentrange = $upcomingseconds->get_most_recent_prediction_range(); $newmostrecentrange = reset($newmostrecentrange); $this->assertGreaterThan($nranges, $nnewranges); $this->assertGreaterThan($ntrainingranges, $nnewtrainingranges); $this->assertGreaterThan($mostrecentrange['time'], $newmostrecentrange['time']); + + // All the ranges but the last one should return the same values. + array_pop($ranges); + array_pop($newranges); + foreach ($ranges as $key => $range) { + $this->assertEquals($newranges[$key]['start'], $range['start']); + $this->assertEquals($newranges[$key]['end'], $range['end']); + $this->assertEquals($newranges[$key]['time'], $range['time']); + } + } + + /** + * Mocks core_analytics\analysis caching of the first time analysables were analysed. + * + * @param int $modelid + * @param int $analysableid + * @param array $range + * @return null + */ + private function mock_cache_first_analysis_caching($modelid, $analysableid, $range) { + $cache = \cache::make('core', 'modelfirstanalyses'); + $cache->set($modelid . '_' . $analysableid, $range['time']); } }