From 16cb4f32a0a7531e91ed43bbe2fd62c39f37b7bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Monlla=C3=B3?= Date: Fri, 23 Aug 2019 16:39:02 +0800 Subject: [PATCH] MDL-66536 analytics: Indicators can add extra data for targets --- analytics/classes/analysis.php | 3 + analytics/classes/calculable.php | 41 ++++ analytics/classes/calculation_info.php | 184 ++++++++++++++++++ analytics/classes/insights_generator.php | 61 ++++-- analytics/classes/local/target/base.php | 43 ++++ analytics/classes/model.php | 20 ++ analytics/classes/prediction_action.php | 23 ++- .../templates/insight_info_message.mustache | 31 +-- .../insight_info_message_prediction.mustache | 56 +++--- .../templates/notification_styles.mustache | 56 ++++++ analytics/tests/calculation_info_test.php | 105 ++++++++++ analytics/upgrade.txt | 4 + .../analytics/indicator/activities_due.php | 15 +- lang/en/analytics.php | 1 + lang/en/cache.php | 1 + lang/en/calendar.php | 1 + lang/en/moodle.php | 3 + lib/db/caches.php | 7 + .../target/upcoming_activities_due.php | 74 +++++++ ...oming_activities_due_insight_body.mustache | 92 +++++++++ version.php | 2 +- 21 files changed, 739 insertions(+), 84 deletions(-) create mode 100644 analytics/classes/calculation_info.php create mode 100644 analytics/templates/notification_styles.mustache create mode 100644 analytics/tests/calculation_info_test.php create mode 100644 user/templates/upcoming_activities_due_insight_body.mustache diff --git a/analytics/classes/analysis.php b/analytics/classes/analysis.php index ddb9c6fce5b..ad39b48d0ac 100644 --- a/analytics/classes/analysis.php +++ b/analytics/classes/analysis.php @@ -477,6 +477,9 @@ class analysis { list($samplesfeatures, $newindicatorcalculations, $indicatornotnulls) = $rangeindicator->calculate($sampleids, $this->analyser->get_samples_origin(), $range['start'], $range['end'], $prevcalculations); + // Associate the extra data generated by the indicator to this range index. + $rangeindicator->save_calculation_info($timesplitting, $rangeindex); + // Free memory ASAP. unset($rangeindicator); gc_collect_cycles(); diff --git a/analytics/classes/calculable.php b/analytics/classes/calculable.php index ceea63a95eb..7ef6b2db71d 100644 --- a/analytics/classes/calculable.php +++ b/analytics/classes/calculable.php @@ -65,6 +65,11 @@ abstract class calculable { */ protected $sampledata = array(); + /** + * @var \core_analytics\calculation_info|null + */ + protected $calculationinfo = null; + /** * Returns a lang_string object representing the name for the indicator or target. * @@ -143,6 +148,42 @@ abstract class calculable { return $this->sampledata[$sampleid][$elementname]; } + /** + * Adds info related to the current calculation for later use when generating insights. + * + * Note that the data in $info array is reused across multiple samples, if you want to add data just for this + * sample you can use the sample id as key. + * + * Please, note that you should be careful with how much data you add here as it can kill the server memory. + * + * @param int $sampleid The sample id this data is associated with + * @param array $info The data. Indexed by an id unique across the site. E.g. an activity id. + * @return null + */ + protected final function add_shared_calculation_info(int $sampleid, array $info) { + if (is_null($this->calculationinfo)) { + // Lazy loading. + $this->calculationinfo = new \core_analytics\calculation_info(); + } + + $this->calculationinfo->add_shared($sampleid, $info); + } + + /** + * Stores in MUC the previously added data and it associates it to the provided $calculable. + * + * Flagged as final as we don't want people to extend this, it is likely to be moved to \core_analytics\calculable + * + * @param \core_analytics\local\time_splitting\base $timesplitting + * @param int $rangeindex + * @return null + */ + public final function save_calculation_info(\core_analytics\local\time_splitting\base $timesplitting, int $rangeindex) { + if (!is_null($this->calculationinfo)) { + $this->calculationinfo->save($this, $timesplitting, $rangeindex); + } + } + /** * Returns the number of weeks a time range contains. * diff --git a/analytics/classes/calculation_info.php b/analytics/classes/calculation_info.php new file mode 100644 index 00000000000..2be73d21d5d --- /dev/null +++ b/analytics/classes/calculation_info.php @@ -0,0 +1,184 @@ +. + +/** + * Extra information generated during the analysis by calculable elements. + * + * @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; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Extra information generated during the analysis by calculable elements. + * + * The main purpose of this request cache is to allow calculable elements to + * store data during their calculations for further use at a later stage efficiently. + * + * @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 calculation_info { + + /** + * @var array + */ + private $info = []; + + /** + * @var mixed[] + */ + private $samplesinfo = []; + + /** + * Adds info related to the current calculation for later use when generating insights. + * + * Note that the data in $info array is reused across multiple samples, if you want to add data just for this + * sample you can use the sample id as key. + * + * We store two different arrays so objects that appear multiple times for different samples + * appear just once in memory. + * + * @param int $sampleid The sample id this data is associated with + * @param array $info The data. Indexed by an id unique across the site. E.g. an activity id. + * @return null + */ + public function add_shared(int $sampleid, array $info) { + + // We can safely overwrite the existing keys because the provided info is supposed to be unique + // for the indicator. + $this->info = $info + $this->info; + + // We also need to store the association between the info provided and the sample. + $this->samplesinfo[$sampleid] = array_keys($info); + } + + /** + * Stores in MUC the previously added data and it associates it to the provided $calculable. + * + * @param \core_analytics\calculable $calculable + * @param \core_analytics\local\time_splitting\base $timesplitting + * @param int $rangeindex + * @return null + */ + public function save(\core_analytics\calculable $calculable, \core_analytics\local\time_splitting\base $timesplitting, + int $rangeindex) { + + $calculableclass = get_class($calculable); + $cache = \cache::make('core', 'calculablesinfo'); + + foreach ($this->info as $key => $value) { + $datakey = self::get_data_key($calculableclass, $key); + + // We do not overwrite existing data. + if (!$cache->has($datakey)) { + $cache->set($datakey, $value); + } + } + + foreach ($this->samplesinfo as $sampleid => $infokeys) { + $uniquesampleid = $timesplitting->append_rangeindex($sampleid, $rangeindex); + $samplekey = self::get_sample_key($uniquesampleid); + + // Update the cached data adding the new indicator data. + $cacheddata = $cache->get($samplekey); + $cacheddata[$calculableclass] = $infokeys; + $cache->set($samplekey, $cacheddata); + } + + // Empty the in-memory arrays now that it is in the cache. + $this->info = []; + $this->samplesinfo = []; + } + + /** + * Pulls the info related to the provided records out from the cache. + * + * Note that this function purges 'calculablesinfo' cache. + * + * @param \stdClass[] $predictionrecords + * @return array|false + */ + public static function pull_info(array $predictionrecords) { + + $cache = \cache::make('core', 'calculablesinfo'); + + foreach ($predictionrecords as $uniquesampleid => $predictionrecord) { + + $sampleid = $predictionrecord->sampleid; + + $sampleinfo = $cache->get(self::get_sample_key($uniquesampleid)); + + // MUC returns (or should return) copies of the data and we want a single copy of it so + // we store the data here and reference it from each sample. Samples data should not be + // changed afterwards. + $data = []; + + if ($sampleinfo) { + foreach ($sampleinfo as $calculableclass => $infokeys) { + + foreach ($infokeys as $infokey) { + + // We don't need to retrieve data back from MUC if we already have it. + if (!isset($data[$calculableclass][$infokey])) { + $datakey = self::get_data_key($calculableclass, $infokey); + $data[$calculableclass][$infokey] = $cache->get($datakey); + } + + $samplesdatakey = $calculableclass . ':extradata'; + $samplesdata[$sampleid][$samplesdatakey][$infokey] = & $data[$calculableclass][$infokey]; + } + } + } + } + + // Free memory ASAP. We can replace the purge call by a delete_many if we are interested on allowing + // multiple calls to pull_info passing in different $sampleids. + $cache->purge(); + + if (empty($samplesdata)) { + return false; + } + + return $samplesdata; + } + + /** + * Gets the key used to store data. + * + * @param string $calculableclass + * @param string|int $key + * @return string + */ + private static function get_data_key(string $calculableclass, $key): string { + return 'data:' . $calculableclass . ':' . $key; + } + + /** + * Gets the key used to store samples. + * + * @param string $uniquesampleid + * @return string + */ + private static function get_sample_key(string $uniquesampleid): string { + return 'sample:' . $uniquesampleid; + } +} \ No newline at end of file diff --git a/analytics/classes/insights_generator.php b/analytics/classes/insights_generator.php index cb24ccdc251..f3e8c0c4ab7 100644 --- a/analytics/classes/insights_generator.php +++ b/analytics/classes/insights_generator.php @@ -71,7 +71,6 @@ class insights_generator { * @return null */ public function generate($samplecontexts, $predictions) { - global $OUTPUT; $analyserclass = $this->target->get_analyser_class(); @@ -89,7 +88,7 @@ class insights_generator { foreach ($users as $user) { $this->set_notification_language($user); - list($insighturl, $fullmessage, $fullmessagehtml) = $this->prediction_info($prediction); + list($insighturl, $fullmessage, $fullmessagehtml) = $this->prediction_info($prediction, $context, $user); $this->notification($context, $user, $insighturl, $fullmessage, $fullmessagehtml); } } @@ -99,6 +98,10 @@ class insights_generator { // Iterate through the context and the users in each context. foreach ($samplecontexts as $context) { + // Weird to pass both the context and the contextname to a method right, but this way we don't add unnecessary + // db reads calling get_context_name() multiple times. + $contextname = $context->get_context_name(false); + $users = $this->target->get_insights_users($context); foreach ($users as $user) { @@ -106,10 +109,8 @@ class insights_generator { $insighturl = $this->target->get_insight_context_url($this->modelid, $context); - $fullmessage = get_string('insightinfomessage', 'analytics', $insighturl->out(false)); - $fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message', - ['url' => $insighturl->out(false)] - ); + list($fullmessage, $fullmessagehtml) = $this->target->get_insight_body($context, $contextname, $user, + $insighturl); $this->notification($context, $user, $insighturl, $fullmessage, $fullmessagehtml); } @@ -177,45 +178,69 @@ class insights_generator { /** * Extracts info from the prediction for display purposes. * - * @param \core_analytics\prediction $prediction + * @param \core_analytics\prediction $prediction + * @param \context $context + * @param \stdClass $user * @return array Three items array with formats [\moodle_url, string, string] */ - private function prediction_info(\core_analytics\prediction $prediction) { + private function prediction_info(\core_analytics\prediction $prediction, \context $context, \stdClass $user) { global $OUTPUT; + // The prediction actions get passed to the target so that it can show them in its preferred way. $predictionactions = $this->target->prediction_actions($prediction, true, true); + $predictioninfo = $this->target->get_insight_body_for_prediction($context, $user, $prediction, $predictionactions); // For FORMAT_PLAIN. - $fullmessageplaintext = ''; + $fullmessageplaintext = ''; + if (!empty($predictioninfo[FORMAT_PLAIN])) { + $fullmessageplaintext .= $predictioninfo[FORMAT_PLAIN]; + } + + $insighturl = $predictioninfo['url'] ?? null; // For FORMAT_HTML. $messageactions = []; - $insighturl = null; foreach ($predictionactions as $action) { $actionurl = $action->get_url(); - $opentoblank = false; if (!$actionurl->get_param('forwardurl')) { $params = ['actionvisiblename' => $action->get_text(), 'target' => '_blank']; $actiondoneurl = new \moodle_url('/report/insights/done.php', $params); // Set the forward url to the 'done' script. $actionurl->param('forwardurl', $actiondoneurl->out(false)); - - $opentoblank = true; } if (empty($insighturl)) { // We use the primary action url as insight url so we log that the user followed the provided link. $insighturl = $action->get_url(); } - $actiondata = (object)['url' => $action->get_url()->out(false), 'text' => $action->get_text(), - 'opentoblank' => $opentoblank]; + + $actiondata = (object)['url' => $action->get_url()->out(false), 'text' => $action->get_text()]; + + // Basic message for people who still lives in the 90s. $fullmessageplaintext .= get_string('insightinfomessageaction', 'analytics', $actiondata) . PHP_EOL; - $messageactions[] = $actiondata; + + // We now process the HTML version actions, with a special treatment for useful/notuseful. + if ($action->get_action_name() === 'fixed') { + $usefulurl = $actiondata->url; + } else if ($action->get_action_name() === 'notuseful') { + $notusefulurl = $actiondata->url; + } else { + $messageactions[] = $actiondata; + } } - $fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message_prediction', - ['actions' => $messageactions]); + // Extra condition because we don't want to show the yes/no unless we have urls for both of them. + if (!empty($usefulurl) && !empty($notusefulurl)) { + $usefulbuttons = ['usefulurl' => $usefulurl, 'notusefulurl' => $notusefulurl]; + } + + $contextinfo = [ + 'usefulbuttons' => $usefulbuttons, + 'actions' => $messageactions, + 'body' => $predictioninfo[FORMAT_HTML] ?? '' + ]; + $fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message_prediction', $contextinfo); return [$insighturl, $fullmessageplaintext, $fullmessagehtml]; } diff --git a/analytics/classes/local/target/base.php b/analytics/classes/local/target/base.php index 0db27aa6d40..579b911015f 100644 --- a/analytics/classes/local/target/base.php +++ b/analytics/classes/local/target/base.php @@ -288,6 +288,49 @@ abstract class base extends \core_analytics\calculable { return get_string('insightmessagesubject', 'analytics', $context->get_context_name()); } + /** + * Returns the body message for an insight with multiple predictions. + * + * This default method is executed when the analysable used by the model generates multiple insight + * for each analysable (one_sample_per_analysable === false) + * + * @param \context $context + * @param string $contextname + * @param \stdClass $user + * @param \moodle_url $insighturl + * @return string[] 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; + + $fullmessage = get_string('insightinfomessageplain', 'analytics', $insighturl->out(false)); + $fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message', + ['url' => $insighturl->out(false), 'insightinfomessage' => get_string('insightinfomessagehtml', 'analytics')] + ); + + return [$fullmessage, $fullmessagehtml]; + } + + /** + * Returns the body message for an insight for a single prediction. + * + * This default method is executed when the analysable used by the model generates one insight + * for each analysable (one_sample_per_analysable === true) + * + * @param \context $context + * @param \stdClass $user + * @param \core_analytics\prediction $prediction + * @param \core_analytics\prediction_action[] $predictionactions Passed by reference to remove duplicate links to actions. + * @return array Plain text msg, HTML message and the main URL for this + * insight (you can return null if you are happy with the + * default insight URL calculated in prediction_info()) + */ + public function get_insight_body_for_prediction(\context $context, \stdClass $user, \core_analytics\prediction $prediction, + array &$predictionactions): array { + // No extra message by default. + return [FORMAT_PLAIN => '', FORMAT_HTML => '', 'url' => null]; + } + /** * Returns an instance of the child class. * diff --git a/analytics/classes/model.php b/analytics/classes/model.php index 939cf6c5681..9632347ee2c 100644 --- a/analytics/classes/model.php +++ b/analytics/classes/model.php @@ -951,6 +951,8 @@ class model { $predictionrecords = $this->add_prediction_ids($predictionrecords); $samplesdata = $this->predictions_sample_data($predictionrecords); + $samplesdata = $this->append_calculations_info($predictionrecords, $samplesdata); + $predictions = array_map(function($predictionobj) use ($samplesdata) { $prediction = new \core_analytics\prediction($predictionobj, $samplesdata[$predictionobj->sampleid]); return $prediction; @@ -1426,6 +1428,24 @@ class model { return $samplesdata; } + /** + * Appends the calculation info to the samples data. + * + * @param \stdClass[] $predictionrecords + * @param array $samplesdata + * @return array + */ + public function append_calculations_info(array $predictionrecords, array $samplesdata): array { + + if ($extrainfo = calculation_info::pull_info($predictionrecords)) { + foreach ($samplesdata as $sampleid => $data) { + // The extra info come prefixed by extra: so we will not have overwrites here. + $samplesdata[$sampleid] = $samplesdata[$sampleid] + $extrainfo[$sampleid]; + } + } + return $samplesdata; + } + /** * Returns the description of a sample * diff --git a/analytics/classes/prediction_action.php b/analytics/classes/prediction_action.php index 6f3ee338e13..91b87bf4832 100644 --- a/analytics/classes/prediction_action.php +++ b/analytics/classes/prediction_action.php @@ -68,10 +68,7 @@ class prediction_action { $this->actionname = $actionname; $this->text = $text; - // We want to track how effective are our suggested actions, we pass users through a script that will log these actions. - $params = array('action' => $this->actionname, 'predictionid' => $prediction->get_prediction_data()->id, - 'forwardurl' => $actionurl->out(false)); - $this->url = new \moodle_url('/report/insights/action.php', $params); + $this->url = self::transform_to_forward_url($actionurl, $actionname, $prediction->get_prediction_data()->id); if ($primary === false) { $this->actionlink = new \action_menu_link_secondary($this->url, $icon, $this->text, $attributes); @@ -114,4 +111,22 @@ class prediction_action { public function get_text() { return $this->text; } + + /** + * Transforms the provided url to an action url so we can record the user actions. + * + * Note that it is the caller responsibility to check that the provided actionname is valid for the prediction target. + * + * @param \moodle_url $actionurl + * @param string $actionname + * @param int $predictionid + * @return \moodle_url + */ + public static function transform_to_forward_url(\moodle_url $actionurl, string $actionname, int $predictionid): \moodle_url { + + // We want to track how effective are our suggested actions, we pass users through a script that will log these actions. + $params = ['action' => $actionname, 'predictionid' => $predictionid, + 'forwardurl' => $actionurl->out(false)]; + return new \moodle_url('/report/insights/action.php', $params); + } } diff --git a/analytics/templates/insight_info_message.mustache b/analytics/templates/insight_info_message.mustache index 54426a41b8f..71de268fbac 100644 --- a/analytics/templates/insight_info_message.mustache +++ b/analytics/templates/insight_info_message.mustache @@ -27,34 +27,13 @@ Example context (json): { - "url": "https://moodle.org" + "url": "https://moodle.org", + "insightinfomessage": "This insight is very useful because bla bla bla." } }} - - -{{#str}} insightinfomessagehtml, analytics {{/str}} +{{{insightinfomessage}}}

-{{#str}} viewinsight, analytics {{/str}} \ No newline at end of file +{{#str}} viewinsight, analytics {{/str}} \ No newline at end of file diff --git a/analytics/templates/insight_info_message_prediction.mustache b/analytics/templates/insight_info_message_prediction.mustache index 7bd76de7c0d..d6b2f09b877 100644 --- a/analytics/templates/insight_info_message_prediction.mustache +++ b/analytics/templates/insight_info_message_prediction.mustache @@ -27,44 +27,32 @@ Example context (json): { - "actions": [ - { - "url": "https://moodle.org", - "text": "Moodle" - }, { - "url": "https://en.wikipedia.org/wiki/Noodle", - "text": "Noodle", - "opentoblank": 1 - } - ] + "body": "I am a link in a text body.", + "usefulbuttons": { + "usefulurl": "https://en.wikipedia.org/wiki/Noodle", + "notusefulurl": "https://en.wikipedia.org/wiki/Noodle" + } } }} -{{! Default btn-default styles. These styles are not applied to Moodle's web UI as there is a body:not(.dir-ltr):not(.dir-rtl)}} - - +{{> core_analytics/notification_styles}} +{{#body}} +
+ {{{.}}} +
+{{/body}}
+ {{#actions}} - {{text}} + {{text}}

{{/actions}} + +{{#usefulbuttons}} +
+ {{! Using target blank for these actions as they only return a small notification.}} + {{#str}} washelpful, analytics {{/str}} + {{#str}}yes{{/str}} + {{#str}}no{{/str}} +
+{{/usefulbuttons}} diff --git a/analytics/templates/notification_styles.mustache b/analytics/templates/notification_styles.mustache new file mode 100644 index 00000000000..a942177b3de --- /dev/null +++ b/analytics/templates/notification_styles.mustache @@ -0,0 +1,56 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_analytics/notification_styles + + Styles for insights' notifications (only for email). + + These styles are not applied to Moodle's web UI as there is a body:not(.dir-ltr):not(.dir-rtl) + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Example context (json): + { + } +}} + + diff --git a/analytics/tests/calculation_info_test.php b/analytics/tests/calculation_info_test.php new file mode 100644 index 00000000000..4212705828e --- /dev/null +++ b/analytics/tests/calculation_info_test.php @@ -0,0 +1,105 @@ +. + +/** + * Unit tests for the calculation info cache. + * + * @package core_analytics + * @copyright 2019 David MonllaĆ³ {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/fixtures/test_indicator_max.php'); +require_once(__DIR__ . '/fixtures/test_indicator_min.php'); + +/** + * Unit tests for the calculation info cache. + * + * @package core_analytics + * @copyright 2017 David MonllaĆ³ {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class analytics_calculation_info_testcase extends advanced_testcase { + + /** + * test_calculation_info description + * + * @dataProvider provider_test_calculation_info_add_pull + * @param mixed $info1 + * @param mixed $info2 + * @param mixed $info3 + * @param mixed $info4 + * @return null + */ + public function test_calculation_info_add_pull($info1, $info2, $info3, $info4) { + $this->resetAfterTest(); + + $atimesplitting = new \core\analytics\time_splitting\quarters(); + + $indicator1 = new test_indicator_min(); + $indicator2 = new test_indicator_max(); + + $calculationinfo = new \core_analytics\calculation_info(); + $calculationinfo->add_shared(111, [111 => $info1]); + $calculationinfo->add_shared(222, [222 => 'should-get-overwritten-in-next-line']); + $calculationinfo->add_shared(222, [222 => $info2]); + $calculationinfo->save($indicator1, $atimesplitting, 0); + + // We also check that the eheheh does not overwrite the value previously stored in the cache + // during the previous save call. + $calculationinfo->add_shared(222, [222 => 'eheheh']); + $calculationinfo->save($indicator1, $atimesplitting, 0); + + // The method save() should clear the internal attrs in \core_analytics\calculation_info + // so it is fine to reuse the same calculation_info instance. + $calculationinfo->add_shared(111, [111 => $info3]); + $calculationinfo->add_shared(333, [333 => $info4]); + $calculationinfo->save($indicator2, $atimesplitting, 0); + + // We pull data in rangeindex '0' for samples 111, 222 and 333. + $predictionrecords = [ + '111-0' => (object)['sampleid' => '111'], + '222-0' => (object)['sampleid' => '222'], + '333-0' => (object)['sampleid' => '333'], + ]; + $info = \core_analytics\calculation_info::pull_info($predictionrecords); + + $this->assertCount(3, $info); + $this->assertCount(2, $info[111]); + $this->assertCount(1, $info[222]); + $this->assertCount(1, $info[333]); + $this->assertEquals($info1, $info[111]['test_indicator_min:extradata'][111]); + $this->assertEquals($info2, $info[222]['test_indicator_min:extradata'][222]); + $this->assertEquals($info3, $info[111]['test_indicator_max:extradata'][111]); + $this->assertEquals($info4, $info[333]['test_indicator_max:extradata'][333]); + + // The calculationinfo cache gets emptied. + $this->assertFalse(\core_analytics\calculation_info::pull_info($predictionrecords)); + } + + /** + * provider_test_calculation_info_add_pull + * + * @return mixed[] + */ + public function provider_test_calculation_info_add_pull() { + return [ + 'mixed-types' => ['asd', true, [123, 123, 123], (object)['asd' => 'fgfg']], + ]; + } +} diff --git a/analytics/upgrade.txt b/analytics/upgrade.txt index 3a12570b4d4..774b4fcc06b 100644 --- a/analytics/upgrade.txt +++ b/analytics/upgrade.txt @@ -8,6 +8,10 @@ information provided here is intended especially for developers. to the new description of the field. * A new target::can_use_timesplitting method must be implemented to discard time-splitting methods that can not be used on a target. +* Targets can now implement get_insight_body and get_insight_body_for_prediction to set the body of the insight. +* 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') === 3.7 === diff --git a/course/classes/analytics/indicator/activities_due.php b/course/classes/analytics/indicator/activities_due.php index b87da32734f..297d31eda74 100644 --- a/course/classes/analytics/indicator/activities_due.php +++ b/course/classes/analytics/indicator/activities_due.php @@ -73,6 +73,7 @@ class activities_due extends \core_analytics\local\indicator\binary { $actionevents = \core_calendar_external::get_calendar_action_events_by_timesort($starttime, $endtime, 0, 1, true, $user->id); + $useractionevents = []; if ($actionevents->events) { // We first need to check that at least one of the core_calendar_provide_event_action @@ -80,9 +81,21 @@ class activities_due extends \core_analytics\local\indicator\binary { foreach ($actionevents->events as $event) { $nparams = $this->get_provide_event_action_num_params($event->modulename); if ($nparams > 2) { - return self::get_max_value(); + // Just the basic info for the insight as we want a low memory usage. + $useractionevents[$event->id] = (object)[ + 'name' => $event->name, + 'url' => $event->url, + 'time' => $event->timesort, + 'coursename' => $event->course->fullnamedisplay, + 'icon' => $event->icon, + ]; } } + + if (!empty($useractionevents)) { + $this->add_shared_calculation_info($sampleid, $useractionevents); + return self::get_max_value(); + } } return self::get_min_value(); diff --git a/lang/en/analytics.php b/lang/en/analytics.php index ff7e173a0cc..54d0fab222e 100644 --- a/lang/en/analytics.php +++ b/lang/en/analytics.php @@ -150,3 +150,4 @@ $string['viewdetails'] = 'View details'; $string['viewinsight'] = 'View insight'; $string['viewinsightdetails'] = 'View insight details'; $string['viewprediction'] = 'View prediction details'; +$string['washelpful'] = 'Was this helpful?'; diff --git a/lang/en/cache.php b/lang/en/cache.php index e379bb93979..f8955b39d5b 100644 --- a/lang/en/cache.php +++ b/lang/en/cache.php @@ -36,6 +36,7 @@ $string['area'] = 'Area'; $string['caching'] = 'Caching'; $string['cacheadmin'] = 'Cache administration'; $string['cacheconfig'] = 'Configuration'; +$string['cachedef_calculablesinfo'] = 'Analytics calculables info'; $string['cachedef_calendar_subscriptions'] = 'Calendar subscriptions'; $string['cachedef_calendar_categories'] = 'Calendar course categories that a user can access'; $string['cachedef_capabilities'] = 'System capabilities list'; diff --git a/lang/en/calendar.php b/lang/en/calendar.php index 2e31f24bb36..8ed97c428b2 100644 --- a/lang/en/calendar.php +++ b/lang/en/calendar.php @@ -268,5 +268,6 @@ $string['weekly'] = 'Weekly'; $string['weeknext'] = 'Next week'; $string['weekthis'] = 'This week'; $string['when'] = 'When'; +$string['whendate'] = 'When: {$a}'; $string['yesterday'] = 'Yesterday'; $string['youcandeleteallrepeats'] = 'This event is part of a repeating event series. You can delete this event only, or all {$a} events in the series at once.'; diff --git a/lang/en/moodle.php b/lang/en/moodle.php index 2051d59501c..320b511286d 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -2190,6 +2190,9 @@ $string['yes'] = 'Yes'; $string['youareabouttocreatezip'] = 'You are about to create a zip file containing'; $string['youaregoingtorestorefrom'] = 'You are about to start the restore process for'; $string['youhaveupcomingactivitiesdue'] = 'You have upcoming activities due'; +$string['youhaveupcomingactivitiesdueinfo'] = 'Hi {$a}, + +

You have upcoming activities due:'; $string['youneedtoenrol'] = 'To perform that action you need to enrol in this course.'; $string['yourlastlogin'] = 'Your last login was'; $string['yourself'] = 'yourself'; diff --git a/lib/db/caches.php b/lib/db/caches.php index ba9412af15f..eaa6904c8ba 100644 --- a/lib/db/caches.php +++ b/lib/db/caches.php @@ -408,4 +408,11 @@ $definitions = array( 'simpledata' => true, 'staticacceleration' => true ], + + // Information generated during the calculation of indicators. + 'calculablesinfo' => [ + 'mode' => cache_store::MODE_REQUEST, + 'simplekeys' => false, + 'simpledata' => false, + ], ); diff --git a/user/classes/analytics/target/upcoming_activities_due.php b/user/classes/analytics/target/upcoming_activities_due.php index bd3fe9edc02..f598ab21007 100644 --- a/user/classes/analytics/target/upcoming_activities_due.php +++ b/user/classes/analytics/target/upcoming_activities_due.php @@ -169,6 +169,80 @@ class upcoming_activities_due extends \core_analytics\local\target\binary { return false; } + /** + * Returns the body message for an insight of a single prediction. + * + * This default method is executed when the analysable used by the model generates one insight + * for each analysable (one_sample_per_analysable === true) + * + * @param \context $context + * @param \stdClass $user + * @param \core_analytics\prediction $prediction + * @param \core_analytics\prediction_action[] $predictionactions Passed by reference to remove duplicate links to actions. + * @return array Plain text msg, HTML message and the main URL for this + * insight (you can return null if you are happy with the + * default insight URL calculated in prediction_info()) + */ + public function get_insight_body_for_prediction(\context $context, \stdClass $user, \core_analytics\prediction $prediction, + array &$predictionactions): array { + global $OUTPUT; + + $fullmessageplaintext = get_string('youhaveupcomingactivitiesdueinfo', 'moodle', $user->firstname); + + $sampledata = $prediction->get_sample_data(); + $activitiesdue = $sampledata['core_course\analytics\indicator\activities_due:extradata']; + + if (empty($activitiesdue)) { + throw new \coding_exception('The activities_due indicator must be part of the model indicators.'); + } + + $activitiestext = []; + foreach ($activitiesdue as $key => $activitydue) { + + // Human-readable version. + $activitiesdue[$key]->formattedtime = userdate($activitydue->time); + + // We provide the URL to the activity through a script that records the user click. + $activityurl = new \moodle_url($activitydue->url); + $actionurl = \core_analytics\prediction_action::transform_to_forward_url($activityurl, 'viewupcoming', + $prediction->get_prediction_data()->id); + $activitiesdue[$key]->url = $actionurl->out(false); + + if (count($activitiesdue) === 1) { + // We will use this activity as the main URL of this insight. + $insighturl = $actionurl; + } + + $activitiestext[] = $activitydue->name . ': ' . $activitiesdue[$key]->url; + } + + foreach ($predictionactions as $key => $action) { + if ($action->get_action_name() === 'viewupcoming') { + + // Use it as the main URL of the insight if there are multiple activities due. + if (empty($insighturl)) { + $insighturl = $action->get_url(); + } + + // Remove the 'viewupcoming' action from the list of actions for this prediction as the action has + // been included in the link to the activity. + unset($predictionactions[$key]); + break; + } + } + + $activitieshtml = $OUTPUT->render_from_template('core_user/upcoming_activities_due_insight_body', (object) [ + 'activitiesdue' => array_values($activitiesdue), + 'userfirstname' => $user->firstname + ]); + + return [ + FORMAT_PLAIN => $fullmessageplaintext . PHP_EOL . PHP_EOL . implode(PHP_EOL, $activitiestext) . PHP_EOL, + FORMAT_HTML => $activitieshtml, + 'url' => $insighturl, + ]; + } + /** * Adds a view upcoming events action. * diff --git a/user/templates/upcoming_activities_due_insight_body.mustache b/user/templates/upcoming_activities_due_insight_body.mustache new file mode 100644 index 00000000000..eb4fd02100d --- /dev/null +++ b/user/templates/upcoming_activities_due_insight_body.mustache @@ -0,0 +1,92 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_user/upcoming_activities_due_insight_body + + Template for the upcoming activity due insight + + Context variables required for this template: + * activitiesdue array - Data for each activity due. + * userfirstname string - The user firstname. + + Example context (json): + { + "userfirstname": "John", + "activitiesdue": [ + { + "name": "Introduction to ASP is due", + "formattedtime": "31 January 2018", + "coursename": "Programming I", + "url": "https://www.google.com" + } + ] + } +}} + + + +{{#str}} youhaveupcomingactivitiesdueinfo, moodle, {{userfirstname}} {{/str}} +

+ +{{#activitiesdue}} + + + + + + + + + + + + + + + + + +
+ {{#icon}} + {{#pix}} {{key}}, {{component}}, {{title}} {{alttext}} {{/pix}} + {{/icon}} + {{name}} +
{{#str}} whendate, calendar, {{formattedtime}} {{/str}}
{{#str}} coursetitle, moodle, {"course": "{{coursename}}" } {{/str}}
+{{/activitiesdue}} \ No newline at end of file diff --git a/version.php b/version.php index 96dd0d48a88..5b41d66b7cd 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2019091300.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2019091300.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes.