Commit 1611308b authored by David Monllaó's avatar David Monllaó
Browse files

MDL-57791 analytics: Changes after review

- Split model::predict in parts
- JS promises updated according to eslint-plugin-promise
- New API methods replacing direct DB queries
- Reduce insights nav link display cost
- Increase time limit as well as memory for big processes
- Move prediction action event to core
- Dataset write locking and others
- Refine last time range end time
- Removed dodgy splitting method id to int
- Replace admin_setting_predictor output_html overwrite for write_setting overwrite
- New APIs for access control
- Discard invalid samples also during prediction
parent 584ffa4f
......@@ -45,7 +45,7 @@ if ($hassiteconfig) {
$logmanager = get_log_manager();
$readers = $logmanager->get_readers('core\log\sql_reader');
$options = array();
$defaultreader = false;
$defaultreader = null;
foreach ($readers as $plugin => $reader) {
if (!$reader->is_logging()) {
continue;
......
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
......@@ -7,7 +7,7 @@
/**
* @module tool_models/log_info
*/
define(['jquery', 'core/str', 'core/modal_factory'], function($, str, ModalFactory) {
define(['jquery', 'core/str', 'core/modal_factory', 'core/notification'], function($, str, ModalFactory, Notification) {
return {
......@@ -20,19 +20,21 @@ define(['jquery', 'core/str', 'core/modal_factory'], function($, str, ModalFacto
loadInfo : function(id, info) {
var link = $('[data-model-log-id="' + id + '"]');
str.get_string('loginfo', 'tool_models').done(function(langString) {
str.get_string('loginfo', 'tool_models').then(function(langString) {
var bodyInfo = $("<ul>");
for (var i in info) {
bodyInfo.append("<li>" + info[i] + "</li>");
}
bodyInfo.append("</ul>");
ModalFactory.create({
return ModalFactory.create({
title: langString,
body: bodyInfo.html(),
large: true,
}, link);
});
}).catch(Notification.exception);
}
};
});
......@@ -46,10 +46,10 @@ class course_dropout extends \core_analytics\local\target\binary {
return get_string('target:coursedropout', 'tool_models');
}
public function prediction_actions(\core_analytics\prediction $prediction) {
public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false) {
global $USER;
$actions = parent::prediction_actions($prediction);
$actions = parent::prediction_actions($prediction, $includedetailsaction);
$sampledata = $prediction->get_sample_data();
$studentid = $sampledata['user']->id;
......@@ -140,13 +140,14 @@ class course_dropout extends \core_analytics\local\target\binary {
}
/**
* is_valid_sample
* All student enrolments are valid.
*
* @param int $sampleid
* @param \core_analytics\analysable $course
* @param bool $fortraining
* @return bool
*/
public function is_valid_sample($sampleid, \core_analytics\analysable $course) {
public function is_valid_sample($sampleid, \core_analytics\analysable $course, $fortraining = true) {
return true;
}
......
......@@ -48,10 +48,10 @@ class no_teaching extends \core_analytics\local\target\binary {
return get_string('target:noteachingactivity', 'tool_models');
}
public function prediction_actions(\core_analytics\prediction $prediction) {
public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false) {
global $USER;
// No need to call the parent as the only default action is view details and this target only have 1 feature.
// No need to call the parent as the parent's action is view details and this target only have 1 feature.
$actions = array();
$sampledata = $prediction->get_sample_data();
......@@ -110,9 +110,10 @@ class no_teaching extends \core_analytics\local\target\binary {
*
* @param mixed $sampleid
* @param \core_analytics\analysable $analysable
* @param bool $fortraining
* @return void
*/
public function is_valid_sample($sampleid, \core_analytics\analysable $analysable) {
public function is_valid_sample($sampleid, \core_analytics\analysable $analysable, $fortraining = true) {
$course = $this->retrieve('course', $sampleid);
......
......@@ -180,8 +180,6 @@ class model_logs extends \table_sql {
* @param bool $useinitialsbar do you want to use the initials bar.
*/
public function query_db($pagesize, $useinitialsbar = true) {
global $DB;
$total = count($this->model->get_logs());
$this->pagesize($pagesize, $total);
$this->rawdata = $this->model->get_logs($this->get_page_start(), $this->get_page_size());
......
......@@ -39,7 +39,7 @@ class predict_models extends \core\task\scheduled_task {
}
public function execute() {
global $DB, $OUTPUT, $PAGE;
global $OUTPUT, $PAGE;
$models = \core_analytics\manager::get_all_models(true, true);
if (!$models) {
......
......@@ -39,7 +39,7 @@ class train_models extends \core\task\scheduled_task {
}
public function execute() {
global $DB, $OUTPUT, $PAGE;
global $OUTPUT, $PAGE;
$models = \core_analytics\manager::get_all_models(true);
if (!$models) {
......
......@@ -63,8 +63,7 @@ if ($options['modelid'] === false || $options['timesplitting'] === false) {
// We need admin permissions.
\core\session\manager::set_user(get_admin());
$modelobj = $DB->get_record('analytics_models', array('id' => $options['modelid']), '*', MUST_EXIST);
$model = new \core_analytics\model($modelobj);
$model = new \core_analytics\model($options['modelid']);
// Evaluate its suitability to predict accurately.
$model->enable($options['timesplitting']);
......
......@@ -75,8 +75,7 @@ if ($options['filter'] !== false) {
// We need admin permissions.
\core\session\manager::set_user(get_admin());
$modelobj = $DB->get_record('analytics_models', array('id' => $options['modelid']), '*', MUST_EXIST);
$model = new \core_analytics\model($modelobj);
$model = new \core_analytics\model($options['modelid']);
mtrace(get_string('analysingsitedata', 'tool_models'));
......
......@@ -30,9 +30,9 @@ $action = required_param('action', PARAM_ALPHANUMEXT);
$context = context_system::instance();
require_login();
require_capability('moodle/analytics:managemodels', $context);
$model = new \core_analytics\model($id);
\core_analytics\manager::check_can_manage_models();
$params = array('id' => $id, 'action' => $action);
$url = new \moodle_url('/admin/tool/models/model.php', $params);
......
......@@ -30,28 +30,26 @@ require_once(__DIR__ . '/../../lib/adminlib.php');
class admin_setting_predictor extends \admin_setting_configselect {
/**
* Builds HTML to display the control.
* Save a setting
*
* The main purpose of this is to display a warning if the selected predictions processor is not ready.
* @param string $data Unused
* @param string $query
* @return string HTML
* @param string $data
* @return string empty of error string
*/
public function output_html($data, $query='') {
global $CFG, $OUTPUT;
$html = '';
public function write_setting($data) {
if (!$this->load_choices() or empty($this->choices)) {
return '';
}
if (!array_key_exists($data, $this->choices)) {
return ''; // ignore it
}
// Calling it here without checking if it is ready because we check it below and show it as a controlled case.
$selectedprocessor = \core_analytics\manager::get_predictions_processor($data, false);
$isready = $selectedprocessor->is_ready();
if ($isready !== true) {
$html .= $OUTPUT->notification(get_string('errorprocessornotready', 'analytics', $isready));
return get_string('errorprocessornotready', 'analytics', $isready);
}
$html .= parent::output_html($data, $query);
return $html;
return ($this->config_write($this->name, $data) ? '' : get_string('errorsetting', 'admin'));
}
}
......@@ -130,7 +130,8 @@ abstract class calculable {
/**
* Returns the number of weeks a time range contains.
*
* Useful for calculations that depend on the time range duration.
* Useful for calculations that depend on the time range duration. Note that it returns
* a float, rounding the float may lead to inaccurate results.
*
* @param int $starttime
* @param int $endtime
......@@ -141,9 +142,14 @@ abstract class calculable {
throw new \coding_exception('End time timestamp should be greater than start time.');
}
$diff = $endtime - $starttime;
$starttimedt = new \DateTime();
$starttimedt->setTimestamp($starttime);
$starttimedt->setTimezone(\DateTimeZone::UTC);
$endtimedt = new \DateTime();
$endtimedt->setTimestamp($endtime);
$endtimedt->setTimezone(\DateTimeZone::UTC);
// No need to be strict about DST here.
$diff = $endtimedt->getTimestamp() - $starttimedt->getTimestamp();
return $diff / WEEKSECS;
}
......
......@@ -443,9 +443,6 @@ class course implements \core_analytics\analysable {
return false;
}
// TODO Use course_modules_completion's timemodified + COMPLETION_COMPLETE* to discard
// activities that have already been completed.
// We skip activities that were not yet visible or their 'until' was not in this $starttime - $endtime range.
if ($activity->availability) {
$info = new \core_availability\info_module($activity);
......@@ -485,7 +482,6 @@ class course implements \core_analytics\analysable {
}
}
// TODO Think about activities in sectionnum 0.
if ($activity->sectionnum == 0) {
return false;
}
......@@ -533,8 +529,6 @@ class course implements \core_analytics\analysable {
$dateconditions = $info->get_availability_tree()->get_all_children('\availability_date\condition');
foreach ($dateconditions as $condition) {
// Availability API does not allow us to check from / to dates nicely, we need to be naughty.
// TODO Would be nice to expand \availability_date\condition API for this calling a save that
// does not save is weird.
$conditiondata = $condition->save();
if ($conditiondata->d === \availability_date\condition::DIRECTION_FROM &&
......
......@@ -86,18 +86,21 @@ class dataset_manager {
/**
* Mark the analysable as being analysed.
*
* @return void
* @return bool Could we get the lock or not.
*/
public function init_process() {
$lockkey = 'modelid:' . $this->modelid . '-analysableid:' . $this->analysableid .
'-timesplitting:' . self::convert_to_int($this->timesplittingid) . '-includetarget:' . (int)$this->includetarget;
'-timesplitting:' . self::clean_time_splitting_id($this->timesplittingid) . '-includetarget:' . (int)$this->includetarget;
// Large timeout as processes may be quite long.
$lockfactory = \core\lock\lock_config::get_lock_factory('core_analytics');
$this->lock = $lockfactory->get_lock($lockkey, WEEKSECS);
// We release the lock if there is an error during the process.
\core_shutdown_manager::register_function(array($this, 'release_lock'), array($this->lock));
// If it is not ready in 10 secs skip this model + analysable + timesplittingmethod combination
// it will attempt it again during next cron run.
if (!$this->lock = $lockfactory->get_lock($lockkey, 10)) {
return false;
}
return true;
}
/**
......@@ -115,7 +118,7 @@ class dataset_manager {
'filearea' => self::get_filearea($this->includetarget),
'itemid' => $this->modelid,
'contextid' => \context_system::instance()->id,
'filepath' => '/analysable/' . $this->analysableid . '/' . self::convert_to_int($this->timesplittingid) . '/',
'filepath' => '/analysable/' . $this->analysableid . '/' . self::clean_time_splitting_id($this->timesplittingid) . '/',
'filename' => self::get_filename($this->evaluation)
];
......@@ -127,6 +130,10 @@ class dataset_manager {
// Write all this stuff to a tmp file.
$filepath = make_request_directory() . DIRECTORY_SEPARATOR . $filerecord['filename'];
$fh = fopen($filepath, 'w+');
if (!$fh) {
$this->close_process();
throw new \moodle_exception('errorcannotwritedataset', 'analytics', '', $tmpfilepath);
}
foreach ($data as $line) {
fputcsv($fh, $line);
}
......@@ -144,10 +151,6 @@ class dataset_manager {
$this->lock->release();
}
public function release_lock(\core\lock\lock $lock) {
$lock->release();
}
/**
* Returns the previous evaluation file.
*
......@@ -162,7 +165,7 @@ class dataset_manager {
$fs = get_file_storage();
// Evaluation data is always labelled.
return $fs->get_file(\context_system::instance()->id, 'analytics', self::LABELLED_FILEAREA, $modelid,
'/timesplitting/' . self::convert_to_int($timesplittingid) . '/', self::EVALUATION_FILENAME);
'/timesplitting/' . self::clean_time_splitting_id($timesplittingid) . '/', self::EVALUATION_FILENAME);
}
public static function delete_previous_evaluation_file($modelid, $timesplittingid) {
......@@ -183,7 +186,7 @@ class dataset_manager {
// Always evaluation.csv and labelled as it is an evaluation file.
$filearea = self::get_filearea(true);
$filename = self::get_filename(true);
$filepath = '/analysable/' . $analysableid . '/' . self::convert_to_int($timesplittingid) . '/';
$filepath = '/analysable/' . $analysableid . '/' . self::clean_time_splitting_id($timesplittingid) . '/';
return $fs->get_file(\context_system::instance()->id, 'analytics', $filearea, $modelid, $filepath, $filename);
}
......@@ -235,6 +238,9 @@ class dataset_manager {
// Start writing to the merge file.
$wh = fopen($tmpfilepath, 'w');
if (!$wh) {
throw new \moodle_exception('errorcannotwritedataset', 'analytics', '', $tmpfilepath);
}
fputcsv($wh, $varnames);
fputcsv($wh, $values);
......@@ -262,7 +268,7 @@ class dataset_manager {
'filearea' => self::get_filearea($includetarget),
'itemid' => $modelid,
'contextid' => \context_system::instance()->id,
'filepath' => '/timesplitting/' . self::convert_to_int($timesplittingid) . '/',
'filepath' => '/timesplitting/' . self::clean_time_splitting_id($timesplittingid) . '/',
'filename' => self::get_filename($evaluation)
];
......@@ -315,17 +321,14 @@ class dataset_manager {
}
/**
* I know it is not very orthodox...
* Remove all possibly problematic chars from the time splitting method id (id = its full class name).
*
* @param string $string
* @return int
* @param string $timesplittingid
* @return string
*/
protected static function convert_to_int($string) {
$sum = 0;
for ($i = 0; $i < strlen($string); $i++) {
$sum += ord($string[$i]);
}
return $sum;
protected static function clean_time_splitting_id($timesplittingid) {
$timesplittingid = str_replace('\\', '-', $timesplittingid);
return clean_param($timesplittingid, PARAM_ALPHANUMEXT);
}
protected static function get_filename($evaluation) {
......
......@@ -192,11 +192,11 @@ abstract class base {
// Target instances scope is per-analysable (it can't be lower as calculations run once per
// analysable, not time splitting method nor time range).
$target = forward_static_call(array($this->target, 'instance'));
$target = call_user_func(array($this->target, 'instance'));
// We need to check that the analysable is valid for the target even if we don't include targets
// as we still need to discard invalid analysables for the target.
$result = $target->is_valid_analysable($analysable, $includetarget);
$result = $target->is_valid_analysable($analysable, $includetarget, true);
if ($result !== true) {
$a = new \stdClass();
$a->analysableid = $analysable->get_id();
......@@ -217,6 +217,7 @@ abstract class base {
$previousanalysis = \core_analytics\dataset_manager::get_evaluation_analysable_file($this->modelid,
$analysable->get_id(), $timesplitting->get_id());
// 1 week is a partly random time interval, no need to worry about DST.
$boundary = time() - WEEKSECS;
if ($previousanalysis && $previousanalysis->get_timecreated() > $boundary) {
// Recover the previous analysed file and avoid generating a new one.
......@@ -344,18 +345,37 @@ abstract class base {
$this->options['evaluation'], !empty($target));
// Flag the model + analysable + timesplitting as being analysed (prevent concurrent executions).
$dataset->init_process();
if (!$dataset->init_process()) {
// If this model + analysable + timesplitting combination is being analysed we skip this process.
$result->status = \core_analytics\model::NO_DATASET;
$result->message = get_string('analysisinprogress', 'analytics');
return $result;
}
// Remove samples the target consider invalid. Note that we use $this->target, $target will be false
// during prediction, but we still need to discard samples the target considers invalid.
$this->target->add_sample_data($samplesdata);
$this->target->filter_out_invalid_samples($sampleids, $analysable, $target);
if (!$sampleids) {
$result->status = \core_analytics\model::NO_DATASET;
$result->message = get_string('novalidsamples', 'analytics');
$dataset->close_process();
return $result;
}
foreach ($this->indicators as $key => $indicator) {
// The analyser attaches the main entities the sample depends on and are provided to the
// indicator to calculate the sample.
$this->indicators[$key]->add_sample_data($samplesdata);
}
// Provide samples to the target instance (different than $this->target) $target is the new instance we get
// for each analysis in progress.
if ($target) {
// Also provided to the target.
$target->add_sample_data($samplesdata);
}
// Here we start the memory intensive process that will last until $data var is
// unset (until the method is finished basically).
$data = $timesplitting->calculate($sampleids, $this->get_samples_origin(), $this->indicators, $ranges, $target);
......@@ -363,6 +383,7 @@ abstract class base {
if (!$data) {
$result->status = \core_analytics\model::ANALYSE_REJECTED_RANGE_PROCESSOR;
$result->message = get_string('novaliddata', 'analytics');
$dataset->close_process();
return $result;
}
......
......@@ -128,6 +128,11 @@ abstract class community_of_inquiry_activity extends linear {
}
protected function any_feedback($action, \cm_info $cm, $contextid, $user) {
if (!in_array($action, 'submitted', 'replied', 'viewed')) {
throw new \coding_exception('Provided action "' . $action . '" is not valid.');
}
if (empty($this->activitylogs[$contextid])) {
return false;
}
......
......@@ -44,10 +44,7 @@ class user_track_forums extends binary {
}
protected function calculate_sample($sampleid, $samplesorigin, $starttime = false, $endtime = false) {
$user = $this->retrieve('user', $sampleid);
// TODO Return null if forums tracking is the default.
return ($user->trackforums) ? self::get_max_value() : self::get_min_value();
}
}
......@@ -45,7 +45,7 @@ abstract class base extends \core_analytics\calculable {
/**
* Returns the analyser class that should be used along with this target.
*
* @return string
* @return string The full class name as a string
*/
abstract public function get_analyser_class();
......@@ -62,20 +62,21 @@ abstract class base extends \core_analytics\calculable {
abstract public function is_valid_analysable(\core_analytics\analysable $analysable, $fortraining = true);
/**
* is_valid_sample
* Is this sample from the $analysable valid?
*
* @param int $sampleid
* @param \core_analytics\analysable $analysable
* @return void
* @param bool $fortraining
* @return bool
*/
abstract public function is_valid_sample($sampleid, \core_analytics\analysable $analysable);
abstract public function is_valid_sample($sampleid, \core_analytics\analysable $analysable, $fortraining = true);
/**
* Calculates this target for the provided samples.
*
* In case there are no values to return or the provided sample is not applicable just return null.
*
* @param int $sample
* @param int $sampleid
* @param \core_analytics\analysable $analysable
* @param int|false $starttime Limit calculations to start time
* @param int|false $endtime Limit calculations to end time
......@@ -103,36 +104,52 @@ abstract class base extends \core_analytics\calculable {
return false;
}
public function prediction_actions(\core_analytics\prediction $prediction) {
global $PAGE;
/**
* Suggested actions for a user.
*
* @param \core_analytics\prediction $prediction
* @param bool $includedetailsaction
* @return \core_analytics\prediction_action[]
*/
public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false) {
$actions = array();
if ($includedetailsaction) {
$predictionurl = new \moodle_url('/report/insights/prediction.php',
array('id' => $prediction->get_prediction_data()->id));
$predictionurl = new \moodle_url('/report/insights/prediction.php',
array('id' => $prediction->get_prediction_data()->id));
if ($predictionurl->compare($PAGE->url)) {
// We don't show the link to prediction.php if we are already in prediction.php
// prediction.php's $PAGE->set_url call is prior to any core_analytics namespace method call.
return array();
$actions['predictiondetails'] = new \core_analytics\prediction_action('predictiondetails', $prediction,
$predictionurl, new \pix_icon('t/preview', get_string('viewprediction', 'analytics')),
get_string('viewprediction', 'analytics'));
}
return array('predictiondetails' => new \core_analytics\prediction_action('predictiondetails', $prediction, $predictionurl,
new \pix_icon('t/preview', get_string('viewprediction', 'analytics')),
get_string('viewprediction', 'analytics'))
);
return $actions;
}
/**
* Callback to execute once a prediction has been returned from the predictions processor.
*
* @param int $modelid
* @param int $sampleid
* @param int $rangeindex
* @param \context $samplecontext
* @param float|int $prediction
* @param float $predictionscore
* @return void
*/
public function prediction_callback($modelid, $sampleid, $samplecontext, $prediction, $predictionscore) {
public function prediction_callback($modelid, $sampleid, $rangeindex, \context $samplecontext, $prediction, $predictionscore) {
return;
}
public function generate_insights($modelid, $samplecontexts) {
/**
* Generates insights notifications
*
* @param int $modelid
* @param \context[] $samplecontexts
* @return void
*/
public function generate_insight_notifications($modelid, $samplecontexts) {
global $CFG;
foreach ($samplecontexts as $context) {
......@@ -142,12 +159,7 @@ abstract class base extends \core_analytics\calculable {
$insightinfo->contextname = $context->get_context_name();
$subject = get_string('insightmessagesubject', 'analytics', $insightinfo);
if ($context->contextlevel >= CONTEXT_COURSE) {
// Course level notification.
$users = get_enrolled_users($context, 'moodle/analytics:listinsights');
} else {
$users = get_users_by_capability($context, 'moodle/analytics:listinsights');
}
$users = $this->get_insights_users($context);
if (!$coursecontext = $context->get_course_context(false)) {
$coursecontext = \context_course::instance(SITEID);
......@@ -181,6 +193,33 @@ abstract class base extends \core_analytics\calculable {
}
/**
* Returns the list of users that will receive insights notifications.
*
* Feel free to overwrite if you need to but keep in mind that moodle/analytics:listinsights
* capability is required to access the list of insights.
*
* @param \context $context
* @return array
*/
protected function get_insights_users(\context $context) {
if ($context->contextlevel >= CONTEXT_COURSE) {
// At course level or below only enrolled users although this is not ideal for
// teachers assigned at category level.
$users = get_enrolled_users($context, 'moodle/analytics:listinsights');