mirror of
https://github.com/moodle/moodle.git
synced 2025-01-17 21:49:15 +01:00
MDL-66536 analytics: Indicators can add extra data for targets
This commit is contained in:
parent
b024720499
commit
16cb4f32a0
@ -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();
|
||||
|
@ -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.
|
||||
*
|
||||
|
184
analytics/classes/calculation_info.php
Normal file
184
analytics/classes/calculation_info.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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];
|
||||
}
|
||||
|
||||
|
@ -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.
|
||||
*
|
||||
|
@ -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
|
||||
*
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
@ -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}}
|
||||
|
56
analytics/templates/notification_styles.mustache
Normal file
56
analytics/templates/notification_styles.mustache
Normal 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>
|
105
analytics/tests/calculation_info_test.php
Normal file
105
analytics/tests/calculation_info_test.php
Normal 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']],
|
||||
];
|
||||
}
|
||||
}
|
@ -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 ===
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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?';
|
||||
|
@ -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';
|
||||
|
@ -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.';
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
@ -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.
|
||||
*
|
||||
|
92
user/templates/upcoming_activities_due_insight_body.mustache
Normal file
92
user/templates/upcoming_activities_due_insight_body.mustache
Normal 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}}
|
@ -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.
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user