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}}
+
| + {{#icon}} + {{#pix}} {{key}}, {{component}}, {{title}} {{alttext}} {{/pix}} + {{/icon}} + {{name}} + | +
|---|
| {{#str}} whendate, calendar, {{formattedtime}} {{/str}} | +
| {{#str}} coursetitle, moodle, {"course": "{{coursename}}" } {{/str}} | +
| {{#str}} gotoactivity, calendar{{/str}} | +