MDL-66536 analytics: Indicators can add extra data for targets

This commit is contained in:
David Monllaó 2019-08-23 16:39:02 +08:00
parent b024720499
commit 16cb4f32a0
21 changed files with 739 additions and 84 deletions

View File

@ -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();

View File

@ -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.
*

View File

@ -0,0 +1,184 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* 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;
}
}

View File

@ -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];
}

View File

@ -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.
*

View File

@ -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
*

View File

@ -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);
}
}

View File

@ -27,34 +27,13 @@
Example context (json):
{
"url": "https://moodle.org"
"url": "https://moodle.org",
"insightinfomessage": "This insight is very <strong>useful</strong> because bla bla bla."
}
}}
<style>
{{! 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:not(.dir-ltr):not(.dir-rtl) .btn-insight {
margin-bottom: 1rem!important;
margin-right: 1rem!important;
color: #212529;
background-color: #e9ecef;
border-color: #e9ecef
display: inline-block;
font-weight: 400;
text-align: center;
white-space: nowrap;
vertical-align: middle;
user-select: none;
border: 1px solid transparent;
padding: .375rem .75rem;
font-size: .9375rem;
line-height: 1.5;
border-radius: .25rem;
text-decoration: none;
}
</style>
{{#str}} insightinfomessagehtml, analytics {{/str}}
{{{insightinfomessage}}}
<br/><br/>
<a class="btn btn-default btn-insight" href="{{url}}">{{#str}} viewinsight, analytics {{/str}}</a>
<a class="btn btn-outline-primary btn-insight" href="{{url}}">{{#str}} viewinsight, analytics {{/str}}</a>

View File

@ -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 <a href=\"#\">link</a> 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)}}
<style>
body:not(.dir-ltr):not(.dir-rtl) .btn-insight {
margin-bottom: 1rem!important;
margin-right: 1rem!important;
color: #212529;
background-color: #e9ecef;
border-color: #e9ecef
display: inline-block;
font-weight: 400;
text-align: center;
white-space: nowrap;
vertical-align: middle;
user-select: none;
border: 1px solid transparent;
padding: .375rem .75rem;
font-size: .9375rem;
line-height: 1.5;
border-radius: .25rem;
text-decoration: none;
}
</style>
{{> core_analytics/notification_styles}}
{{#body}}
<div>
{{{.}}}
</div>
{{/body}}
<br/>
{{#actions}}
<a class="btn btn-default m-r-1 m-b-1 btn-insight" {{#opentoblank}}target="_blank" {{/opentoblank}}href="{{url}}">{{text}}</a>
<a class="btn btn-outline-primary m-r-1 m-b-1 btn-insight" href="{{url}}">{{text}}</a><br/><br/>
{{/actions}}
{{#usefulbuttons}}
<div>
{{! Using target blank for these actions as they only return a small notification.}}
<strong>{{#str}} washelpful, analytics {{/str}}</strong>
<a href="{{usefulurl}}" target="_blank" class="btn-insight btn btn-outline-primary">{{#str}}yes{{/str}}</a>
<a href="{{notusefulurl}}" target="_blank" class="btn-insight btn btn-outline-primary">{{#str}}no{{/str}}</a>
</div>
{{/usefulbuttons}}

View File

@ -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 <http://www.gnu.org/licenses/>.
}}
{{!
@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):
{
}
}}
<style>
body:not(.dir-ltr):not(.dir-rtl) {
font-family: 'Open Sans', sans-serif;
}
body:not(.dir-ltr):not(.dir-rtl) .btn-insight {
color: #007bff;
background-color: transparent;
display: inline-block;
font-weight: 400;
text-align: center;
white-space: nowrap;
vertical-align: middle;
user-select: none;
border: 1px solid #007bff;
padding: .375rem .75rem;
font-size: .9375rem;
line-height: 1.5;
border-radius: 0;
text-decoration: none;
cursor: pointer;
}
</style>

View File

@ -0,0 +1,105 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* 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']],
];
}
}

View File

@ -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 ===

View File

@ -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();

View File

@ -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?';

View File

@ -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';

View File

@ -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.';

View File

@ -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},
<br/><br/>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';

View File

@ -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,
],
);

View File

@ -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.
*

View File

@ -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 <http://www.gnu.org/licenses/>.
}}
{{!
@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"
}
]
}
}}
<style>
body:not(.dir-ltr):not(.dir-rtl) table.upcoming-activity-due {
font-family: 'Open Sans', sans-serif;
text-align: justify;
margin-bottom: 1rem;
margin-top: 1rem;
}
body:not(.dir-ltr):not(.dir-rtl) table.upcoming-activity-due tr.when {
background-color: #e9ecef;
}
body:not(.dir-ltr):not(.dir-rtl) table.upcoming-activity-due th {
padding: 1rem .75rem 1rem .75rem;
font-weight: 400;
font-size: larger;
border-top: 1px solid #dee2e6;
}
body:not(.dir-ltr):not(.dir-rtl) table.upcoming-activity-due td {
padding: .75rem;
}
body:not(.dir-ltr):not(.dir-rtl) table.upcoming-activity-due td.link {
border-top: 1px solid #dee2e6;
border-bottom: 1px solid #dee2e6;
}
</style>
{{#str}} youhaveupcomingactivitiesdueinfo, moodle, {{userfirstname}} {{/str}}
<br/><br/>
{{#activitiesdue}}
<table class="table upcoming-activity-due">
<thead>
<tr>
<th scope="col" class="h5">
{{#icon}}
{{#pix}} {{key}}, {{component}}, {{title}} {{alttext}} {{/pix}}
{{/icon}}
{{name}}
</th>
</tr>
</thead>
<tbody>
<tr class="when">
<td><strong>{{#str}} whendate, calendar, {{formattedtime}} {{/str}}</strong></td>
</tr>
<tr>
<td>{{#str}} coursetitle, moodle, {"course": "{{coursename}}" } {{/str}}</td>
</tr>
<tr>
<td class="link"><a href="{{url}}">{{#str}} gotoactivity, calendar{{/str}}</a></td>
</tr>
</tbody>
</table>
{{/activitiesdue}}

View File

@ -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.