first commit

This commit is contained in:
CHIEFSOFT\ameye
2024-09-30 18:11:26 -04:00
commit e592ca6823
27270 changed files with 5002257 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
<?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/>.
/**
* Representation of a suggested action.
*
* @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();
/**
* Representation of a suggested action.
*
* @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
*/
abstract class action {
/**
* @var Action type useful.
*/
const TYPE_POSITIVE = 'useful';
/**
* @var Action type notuseful.
*/
const TYPE_NEGATIVE = 'notuseful';
/**
* @var Action type neutral.
*/
const TYPE_NEUTRAL = 'neutral';
/**
* @var string
*/
protected $actionname = null;
/**
* @var \moodle_url
*/
protected $url = null;
/**
* @var \renderable
*/
protected $actionlink = null;
/**
* @var string
*/
protected $text = null;
/** @var string Store the action type. */
protected string $type = '';
/**
* Returns the action name.
*
* @return string
*/
public function get_action_name() {
return $this->actionname;
}
/**
* Returns the url to the action.
*
* @return \moodle_url
*/
public function get_url() {
return $this->url;
}
/**
* Returns the link to the action.
*
* @return \renderable
*/
public function get_action_link() {
return $this->actionlink;
}
/**
* Returns the action text.
* @return string
*/
public function get_text() {
return $this->text;
}
/**
* Sets the type of the action according to its positiveness.
*
* @throws \coding_exception
* @param string|false $type \core_analytics\action::TYPE_POSITIVE, TYPE_NEGATIVE or TYPE_NEUTRAL
*/
public function set_type($type = false) {
if (!$type) {
// Any non-standard action specified by a target is considered positive by default because that is what
// they are meant to be.
$type = self::TYPE_POSITIVE;
}
if ($type !== self::TYPE_POSITIVE && $type !== self::TYPE_NEUTRAL &&
$type !== self::TYPE_NEGATIVE) {
throw new \coding_exception('The provided type must be ' . self::TYPE_POSITIVE . ', ' . self::TYPE_NEUTRAL .
' or ' . self::TYPE_NEGATIVE);
}
$this->type = $type;
}
/**
* Returns the type of action.
*
* @return string The positiveness of the action (self::TYPE_POSITIVE, self::TYPE_NEGATIVE or self::TYPE_NEUTRAL)
*/
public function get_type() {
return $this->type;
}
}
@@ -0,0 +1,72 @@
<?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/>.
/**
* Extension to show an error message if the selected predictor is not available.
*
* @package core_analytics
* @copyright 2017 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();
require_once(__DIR__ . '/../../lib/adminlib.php');
/**
* Extension to show an error message if the selected predictor is not available.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class admin_setting_predictor extends \admin_setting_configselect {
/**
* Save a setting
*
* @param string $data
* @return string empty of error string
*/
public function write_setting($data) {
if (!$this->load_choices() or empty($this->choices)) {
return '';
}
if (!array_key_exists($data, $this->choices)) {
return '';
}
// 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) {
return get_string('errorprocessornotready', 'analytics', $isready);
}
$currentvalue = get_config('analytics', 'predictionsprocessor');
if (!empty($currentvalue) && $currentvalue != str_replace('\\\\', '\\', $data)) {
// Clear all models data.
$models = \core_analytics\manager::get_all_models();
foreach ($models as $model) {
$model->clear();
}
}
return ($this->config_write($this->name, $data) ? '' : get_string('errorsetting', 'admin'));
}
}
+67
View File
@@ -0,0 +1,67 @@
<?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/>.
namespace core_analytics;
/**
* Any element analysers can analyse.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
interface analysable {
/**
* Max timestamp.
*/
const MAX_TIME = 9999999999;
/**
* The analysable unique identifier in the site.
*
* @return int.
*/
public function get_id();
/**
* The analysable human readable name
*
* @return string
*/
public function get_name();
/**
* The analysable context.
*
* @return \context
*/
public function get_context();
/**
* The start of the analysable if there is one.
*
* @return int|false
*/
public function get_start();
/**
* The end of the analysable if there is one.
*
* @return int|false
*/
public function get_end();
}
+959
View File
@@ -0,0 +1,959 @@
<?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/>.
/**
* Runs an analysis of the site.
*
* @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();
/**
* Runs an analysis of the site.
*
* @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 analysis {
/**
* @var \core_analytics\local\analyser\base
*/
private $analyser;
/**
* @var bool Whether to calculate the target or not in this run.
*/
private $includetarget;
/**
* @var \core_analytics\local\analysis\result
*/
private $result;
/**
* @var \core\lock\lock
*/
private $lock;
/**
* Constructor.
*
* @param \core_analytics\local\analyser\base $analyser
* @param bool $includetarget Whether to calculate the target or not.
* @param \core_analytics\local\analysis\result $result
*/
public function __construct(\core_analytics\local\analyser\base $analyser, bool $includetarget,
\core_analytics\local\analysis\result $result) {
$this->analyser = $analyser;
$this->includetarget = $includetarget;
$this->result = $result;
// We cache the first time analysables were analysed because time-splitting methods can depend on these info.
self::fill_firstanalyses_cache($this->analyser->get_modelid());
}
/**
* Runs the analysis.
*
* @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
* @return null
*/
public function run(array $contexts = []) {
$options = $this->analyser->get_options();
// Time limit control.
$modeltimelimit = intval(get_config('analytics', 'modeltimelimit'));
if ($this->includetarget) {
$action = 'training';
} else {
$action = 'prediction';
}
$analysables = $this->analyser->get_analysables_iterator($action, $contexts);
$processedanalysables = $this->get_processed_analysables();
$inittime = microtime(true);
foreach ($analysables as $analysable) {
$processed = false;
if (!$analysable) {
continue;
}
$analysableresults = $this->process_analysable($analysable);
if ($analysableresults) {
$processed = $this->result->add_analysable_results($analysableresults);
if (!$processed) {
$errors = array();
foreach ($analysableresults as $timesplittingid => $result) {
$str = '';
if (count($analysableresults) > 1) {
$str .= $timesplittingid . ': ';
}
$str .= $result->message;
$errors[] = $str;
}
$a = new \stdClass();
$a->analysableid = $analysable->get_name();
$a->errors = implode(', ', $errors);
$this->analyser->add_log(get_string('analysablenotused', 'analytics', $a));
}
}
if (!$options['evaluation']) {
if (empty($processedanalysables[$analysable->get_id()]) ||
$this->analyser->get_target()->always_update_analysis_time() || $processed) {
// We store the list of processed analysables even if the target does not always_update_analysis_time(),
// what always_update_analysis_time controls is the update of the data.
$this->update_analysable_analysed_time($processedanalysables, $analysable->get_id());
}
// Apply time limit.
$timespent = microtime(true) - $inittime;
if ($modeltimelimit <= $timespent) {
break;
}
}
}
// Force GC to clean up the indicator instances used during the last iteration.
$this->analyser->instantiate_indicators();
}
/**
* Get analysables that have been already processed.
*
* @return \stdClass[]
*/
protected function get_processed_analysables(): array {
global $DB;
$params = array('modelid' => $this->analyser->get_modelid());
$params['action'] = ($this->includetarget) ? 'training' : 'prediction';
$select = 'modelid = :modelid and action = :action';
// Weird select fields ordering for performance (analysableid key matching, analysableid is also unique by modelid).
return $DB->get_records_select('analytics_used_analysables', $select,
$params, 'timeanalysed DESC', 'analysableid, modelid, action, firstanalysis, timeanalysed, id AS primarykey');
}
/**
* Processes an analysable
*
* This method returns the general analysable status, an array of files by time splitting method and
* an error message if there is any problem.
*
* @param \core_analytics\analysable $analysable
* @return \stdClass[] Results objects by time splitting method
*/
public function process_analysable(\core_analytics\analysable $analysable): array {
// 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 = call_user_func(array($this->analyser->get_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.
$isvalidresult = $target->is_valid_analysable($analysable, $this->includetarget);
if ($isvalidresult !== true) {
$a = new \stdClass();
$a->analysableid = $analysable->get_name();
$a->result = $isvalidresult;
$this->analyser->add_log(get_string('analysablenotvalidfortarget', 'analytics', $a));
return array();
}
// Process all provided time splitting methods.
$results = array();
foreach ($this->analyser->get_timesplittings() as $timesplitting) {
$cachedresult = $this->result->retrieve_cached_result($timesplitting, $analysable);
if ($cachedresult) {
$result = new \stdClass();
$result->result = $cachedresult;
$results[$timesplitting->get_id()] = $result;
continue;
}
$results[$timesplitting->get_id()] = $this->process_time_splitting($timesplitting, $analysable, $target);
}
return $results;
}
/**
* Processes the analysable samples using the provided time splitting method.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \core_analytics\analysable $analysable
* @param \core_analytics\local\target\base $target
* @return \stdClass Results object.
*/
protected function process_time_splitting(\core_analytics\local\time_splitting\base $timesplitting,
\core_analytics\analysable $analysable, \core_analytics\local\target\base $target): \stdClass {
$options = $this->analyser->get_options();
$result = new \stdClass();
$timesplitting->set_modelid($this->analyser->get_modelid());
if (!$timesplitting->is_valid_analysable($analysable)) {
$result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
$result->message = get_string('invalidanalysablefortimesplitting', 'analytics',
$timesplitting->get_name());
return $result;
}
$timesplitting->set_analysable($analysable);
if (CLI_SCRIPT && !PHPUNIT_TEST) {
mtrace('Analysing id "' . $analysable->get_id() . '" with "' . $timesplitting->get_name() .
'" time splitting method...');
}
// What is a sample is defined by the analyser, it can be an enrolment, a course, a user, a question
// attempt... it is on what we will base indicators calculations.
list($sampleids, $samplesdata) = $this->analyser->get_all_samples($analysable);
if (count($sampleids) === 0) {
$result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
$result->message = get_string('nodata', 'analytics');
return $result;
}
if ($this->includetarget) {
// All ranges are used when we are calculating data for training.
$ranges = $timesplitting->get_training_ranges();
} else {
// The latest range that has not yet been used for prediction (it depends on the time range where we are right now).
$ranges = $timesplitting->get_most_recent_prediction_range();
}
// There is no need to keep track of the evaluated samples and ranges as we always evaluate the whole dataset.
if ($options['evaluation'] === false) {
if (empty($ranges)) {
$result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
$result->message = get_string('noranges', 'analytics');
return $result;
}
// We skip all samples that are already part of a training dataset, even if they have not been used for prediction.
if (!$target::based_on_assumptions()) {
// Targets based on assumptions can not be trained.
$this->filter_out_train_samples($sampleids, $timesplitting);
}
if (count($sampleids) === 0) {
$result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
$result->message = get_string('nonewdata', 'analytics');
return $result;
}
// Only when processing data for predictions.
if (!$this->includetarget) {
// We also filter out samples and ranges that have already been used for predictions.
$predictsamplesrecord = $this->filter_out_prediction_samples_and_ranges($sampleids, $ranges, $timesplitting);
}
if (count($sampleids) === 0) {
$result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
$result->message = get_string('nonewdata', 'analytics');
return $result;
}
if (count($ranges) === 0) {
$result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
$result->message = get_string('nonewranges', 'analytics');
return $result;
}
}
// Flag the model + analysable + timesplitting as being analysed (prevent concurrent executions).
if (!$this->init_analysable_analysis($timesplitting->get_id(), $analysable->get_id())) {
// 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.
try {
$target->add_sample_data($samplesdata);
$target->filter_out_invalid_samples($sampleids, $analysable, $this->includetarget);
} catch (\Throwable $e) {
$this->finish_analysable_analysis();
throw $e;
}
if (!$sampleids) {
$result->status = \core_analytics\model::NO_DATASET;
$result->message = get_string('novalidsamples', 'analytics');
$this->finish_analysable_analysis();
return $result;
}
try {
// Instantiate empty indicators to ensure that no garbage is dragged from previous analyses.
$indicators = $this->analyser->instantiate_indicators();
foreach ($indicators as $key => $indicator) {
// The analyser attaches the main entities the sample depends on and are provided to the
// indicator to calculate the sample.
$indicators[$key]->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 = $this->calculate($timesplitting, $sampleids, $ranges, $target);
} catch (\Throwable $e) {
$this->finish_analysable_analysis();
throw $e;
}
if (!$data) {
$result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
$result->message = get_string('novaliddata', 'analytics');
$this->finish_analysable_analysis();
return $result;
}
try {
// No need to keep track of analysed stuff when evaluating.
if ($options['evaluation'] === false) {
// Save the samples that have been already analysed so they are not analysed again in future.
if ($this->includetarget) {
$this->save_train_samples($sampleids, $timesplitting);
} else {
// The variable $predictsamplesrecord will always be set as filter_out_prediction_samples_and_ranges
// will always be called before it (no evaluation mode and no includetarget).
$this->save_prediction_samples($sampleids, $ranges, $timesplitting, $predictsamplesrecord);
}
}
// We need to pass all the analysis data.
$formattedresult = $this->result->format_result($data, $target, $timesplitting, $analysable);
} catch (\Throwable $e) {
$this->finish_analysable_analysis();
throw $e;
}
if (!$formattedresult) {
$this->finish_analysable_analysis();
throw new \moodle_exception('errorcannotwritedataset', 'analytics');
}
$result->status = \core_analytics\model::OK;
$result->message = get_string('successfullyanalysed', 'analytics');
$result->result = $formattedresult;
// Flag the model + analysable + timesplitting as analysed.
$this->finish_analysable_analysis();
return $result;
}
/**
* Calculates indicators and targets.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param array $sampleids
* @param array $ranges
* @param \core_analytics\local\target\base $target
* @return array|null
*/
public function calculate(\core_analytics\local\time_splitting\base $timesplitting, array &$sampleids,
array $ranges, \core_analytics\local\target\base $target): ?array {
$calculatedtarget = null;
if ($this->includetarget) {
// We first calculate the target because analysable data may still be invalid or none
// of the analysable samples may be valid.
$calculatedtarget = $target->calculate($sampleids, $timesplitting->get_analysable());
// We remove samples we can not calculate their target.
$sampleids = array_filter($sampleids, function($sampleid) use ($calculatedtarget) {
if (is_null($calculatedtarget[$sampleid])) {
return false;
}
return true;
});
}
// No need to continue calculating if the target couldn't be calculated for any sample.
if (empty($sampleids)) {
return null;
}
$dataset = $this->calculate_indicators($timesplitting, $sampleids, $ranges);
if (empty($dataset)) {
return null;
}
// Now that we have the indicators in place we can add the time range indicators (and target if provided) to each of them.
$this->fill_dataset($timesplitting, $dataset, $calculatedtarget);
$this->add_context_metadata($timesplitting, $dataset, $target);
if (!PHPUNIT_TEST && CLI_SCRIPT) {
echo PHP_EOL;
}
return $dataset;
}
/**
* Calculates indicators.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param array $sampleids
* @param array $ranges
* @return array
*/
protected function calculate_indicators(\core_analytics\local\time_splitting\base $timesplitting, array $sampleids,
array $ranges): array {
global $DB;
$options = $this->analyser->get_options();
$dataset = array();
// Faster to run 1 db query per range.
$existingcalculations = array();
if ($timesplitting->cache_indicator_calculations()) {
foreach ($ranges as $rangeindex => $range) {
// Load existing calculations.
$existingcalculations[$rangeindex] = \core_analytics\manager::get_indicator_calculations(
$timesplitting->get_analysable(), $range['start'], $range['end'], $this->analyser->get_samples_origin());
}
}
// Here we store samples which calculations are not all null.
$notnulls = array();
// Fill the dataset samples with indicators data.
$newcalculations = array();
foreach ($this->analyser->get_indicators() as $indicator) {
// Hook to allow indicators to store analysable-dependant data.
$indicator->fill_per_analysable_caches($timesplitting->get_analysable());
// Per-range calculations.
foreach ($ranges as $rangeindex => $range) {
// Indicator instances are per-range.
$rangeindicator = clone $indicator;
$prevcalculations = array();
if (!empty($existingcalculations[$rangeindex][$rangeindicator->get_id()])) {
$prevcalculations = $existingcalculations[$rangeindex][$rangeindicator->get_id()];
}
// Calculate the indicator for each sample in this time range.
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();
gc_mem_caches();
// Copy the features data to the dataset.
foreach ($samplesfeatures as $analysersampleid => $features) {
$uniquesampleid = $timesplitting->append_rangeindex($analysersampleid, $rangeindex);
if (!isset($notnulls[$uniquesampleid]) && !empty($indicatornotnulls[$analysersampleid])) {
$notnulls[$uniquesampleid] = $uniquesampleid;
}
// Init the sample if it is still empty.
if (!isset($dataset[$uniquesampleid])) {
$dataset[$uniquesampleid] = array();
}
// Append the features indicator features at the end of the sample.
$dataset[$uniquesampleid] = array_merge($dataset[$uniquesampleid], $features);
}
if (!$options['evaluation'] && $timesplitting->cache_indicator_calculations()) {
$timecreated = time();
foreach ($newindicatorcalculations as $sampleid => $calculatedvalue) {
// Prepare the new calculations to be stored into DB.
$indcalc = new \stdClass();
$indcalc->contextid = $timesplitting->get_analysable()->get_context()->id;
$indcalc->starttime = $range['start'];
$indcalc->endtime = $range['end'];
$indcalc->sampleid = $sampleid;
$indcalc->sampleorigin = $this->analyser->get_samples_origin();
$indcalc->indicator = $indicator->get_id();
$indcalc->value = $calculatedvalue;
$indcalc->timecreated = $timecreated;
$newcalculations[] = $indcalc;
}
}
}
if (!$options['evaluation'] && $timesplitting->cache_indicator_calculations()) {
$batchsize = self::get_insert_batch_size();
if (count($newcalculations) > $batchsize) {
// We don't want newcalculations array to grow too much as we already keep the
// system memory busy storing $dataset contents.
// Insert from the beginning.
$remaining = array_splice($newcalculations, $batchsize);
// Sorry mssql and oracle, this will be slow.
$DB->insert_records('analytics_indicator_calc', $newcalculations);
$newcalculations = $remaining;
}
}
}
if (!$options['evaluation'] && $timesplitting->cache_indicator_calculations() && $newcalculations) {
// Insert the remaining records.
$DB->insert_records('analytics_indicator_calc', $newcalculations);
}
// Delete rows where all calculations are null.
// We still store the indicator calculation and we still store the sample id as
// processed so we don't have to process this sample again, but we exclude it
// from the dataset because it is not useful.
$nulls = array_diff_key($dataset, $notnulls);
foreach ($nulls as $uniqueid => $ignoredvalues) {
unset($dataset[$uniqueid]);
}
return $dataset;
}
/**
* Adds time range indicators and the target to each sample.
*
* This will identify the sample as belonging to a specific range.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param array $dataset
* @param array|null $calculatedtarget
* @return null
*/
protected function fill_dataset(\core_analytics\local\time_splitting\base $timesplitting,
array &$dataset, ?array $calculatedtarget = null) {
$nranges = count($timesplitting->get_distinct_ranges());
foreach ($dataset as $uniquesampleid => $unmodified) {
list($analysersampleid, $rangeindex) = $timesplitting->infer_sample_info($uniquesampleid);
// No need to add range features if this time splitting method only defines one time range.
if ($nranges > 1) {
// 1 column for each range.
$timeindicators = array_fill(0, $nranges, 0);
$timeindicators[$rangeindex] = 1;
$dataset[$uniquesampleid] = array_merge($timeindicators, $dataset[$uniquesampleid]);
}
if ($calculatedtarget) {
// Add this sampleid's calculated target and the end.
$dataset[$uniquesampleid][] = $calculatedtarget[$analysersampleid];
} else {
// Add this sampleid, it will be used to identify the prediction that comes back from
// the predictions processor.
array_unshift($dataset[$uniquesampleid], $uniquesampleid);
}
}
}
/**
* Updates the analysable analysis time.
*
* @param array $processedanalysables
* @param int $analysableid
* @return null
*/
protected function update_analysable_analysed_time(array $processedanalysables, int $analysableid) {
global $DB;
$now = time();
if (!empty($processedanalysables[$analysableid])) {
$obj = $processedanalysables[$analysableid];
$obj->id = $obj->primarykey;
unset($obj->primarykey);
$obj->timeanalysed = $now;
$DB->update_record('analytics_used_analysables', $obj);
} else {
$obj = new \stdClass();
$obj->modelid = $this->analyser->get_modelid();
$obj->action = ($this->includetarget) ? 'training' : 'prediction';
$obj->analysableid = $analysableid;
$obj->firstanalysis = $now;
$obj->timeanalysed = $now;
$obj->primarykey = $DB->insert_record('analytics_used_analysables', $obj);
// Update the cache just in case it is used in the same request.
$key = $this->analyser->get_modelid() . '_' . $analysableid;
$cache = \cache::make('core', 'modelfirstanalyses');
$cache->set($key, $now);
}
}
/**
* Fills a cache containing the first time each analysable in the provided model was analysed.
*
* @param int $modelid
* @param int|null $analysableid
* @return null
*/
public static function fill_firstanalyses_cache(int $modelid, ?int $analysableid = null) {
global $DB;
// Using composed keys instead of cache $identifiers because of MDL-65358.
$primarykey = $DB->sql_concat($modelid, "'_'", 'analysableid');
$sql = "SELECT $primarykey AS id, MIN(firstanalysis) AS firstanalysis
FROM {analytics_used_analysables} aua
WHERE modelid = :modelid";
$params = ['modelid' => $modelid];
if ($analysableid) {
$sql .= " AND analysableid = :analysableid";
$params['analysableid'] = $analysableid;
}
$sql .= " GROUP BY modelid, analysableid ORDER BY analysableid";
$firstanalyses = $DB->get_records_sql($sql, $params);
if ($firstanalyses) {
$cache = \cache::make('core', 'modelfirstanalyses');
$firstanalyses = array_map(function($record) {
return $record->firstanalysis;
}, $firstanalyses);
$cache->set_many($firstanalyses);
}
return $firstanalyses;
}
/**
* Adds dataset context info.
*
* The final dataset document will look like this:
* ----------------------------------------------------
* metadata1,metadata2,metadata3,.....
* value1, value2, value3,.....
*
* header1,header2,header3,header4,.....
* stud1value1,stud1value2,stud1value3,stud1value4,.....
* stud2value1,stud2value2,stud2value3,stud2value4,.....
* .....
* ----------------------------------------------------
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param array $dataset
* @param \core_analytics\local\target\base $target
* @return null
*/
protected function add_context_metadata(\core_analytics\local\time_splitting\base $timesplitting, array &$dataset,
\core_analytics\local\target\base $target) {
$headers = $this->get_headers($timesplitting, $target);
// This will also reset samples' dataset keys.
array_unshift($dataset, $headers);
}
/**
* Returns the headers for the csv file based on the indicators and the target.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \core_analytics\local\target\base $target
* @return string[]
*/
public function get_headers(\core_analytics\local\time_splitting\base $timesplitting,
\core_analytics\local\target\base $target): array {
// 3rd column will contain the indicator ids.
$headers = array();
if (!$this->includetarget) {
// The first column is the sampleid.
$headers[] = 'sampleid';
}
// We always have 1 column for each time splitting method range, it does not depend on how
// many ranges we calculated.
$ranges = $timesplitting->get_distinct_ranges();
if (count($ranges) > 1) {
foreach ($ranges as $rangeindex) {
$headers[] = 'range/' . $rangeindex;
}
}
// Model indicators.
foreach ($this->analyser->get_indicators() as $indicator) {
$headers = array_merge($headers, $indicator::get_feature_headers());
}
// The target as well.
if ($this->includetarget) {
$headers[] = $target->get_id();
}
return $headers;
}
/**
* Filters out samples that have already been used for training.
*
* @param int[] $sampleids
* @param \core_analytics\local\time_splitting\base $timesplitting
* @return null
*/
protected function filter_out_train_samples(array &$sampleids, \core_analytics\local\time_splitting\base $timesplitting) {
global $DB;
$params = array('modelid' => $this->analyser->get_modelid(), 'analysableid' => $timesplitting->get_analysable()->get_id(),
'timesplitting' => $timesplitting->get_id());
$trainingsamples = $DB->get_records('analytics_train_samples', $params);
// Skip each file trained samples.
foreach ($trainingsamples as $trainingfile) {
$usedsamples = json_decode($trainingfile->sampleids, true);
if (!empty($usedsamples)) {
// Reset $sampleids to $sampleids minus this file's $usedsamples.
$sampleids = array_diff_key($sampleids, $usedsamples);
}
}
}
/**
* Filters out samples that have already been used for prediction.
*
* @param int[] $sampleids
* @param array $ranges
* @param \core_analytics\local\time_splitting\base $timesplitting
* @return \stdClass|null The analytics_predict_samples record or null
*/
protected function filter_out_prediction_samples_and_ranges(array &$sampleids, array &$ranges,
\core_analytics\local\time_splitting\base $timesplitting) {
if (count($ranges) > 1) {
throw new \coding_exception('$ranges argument should only contain one range');
}
$rangeindex = key($ranges);
$predictedrange = $this->get_predict_samples_record($timesplitting, $rangeindex);
if (!$predictedrange) {
// Nothing to filter out.
return null;
}
$predictedrange->sampleids = json_decode($predictedrange->sampleids, true);
$missingsamples = array_diff_key($sampleids, $predictedrange->sampleids);
if (count($missingsamples) === 0) {
// All samples already calculated.
unset($ranges[$rangeindex]);
return null;
}
// Replace the list of samples by the one excluding samples that already got predictions at this range.
$sampleids = $missingsamples;
return $predictedrange;
}
/**
* Returns a predict samples record.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param int $rangeindex
* @return \stdClass|false
*/
private function get_predict_samples_record(\core_analytics\local\time_splitting\base $timesplitting, int $rangeindex) {
global $DB;
$params = array('modelid' => $this->analyser->get_modelid(), 'analysableid' => $timesplitting->get_analysable()->get_id(),
'timesplitting' => $timesplitting->get_id(), 'rangeindex' => $rangeindex);
$predictedrange = $DB->get_record('analytics_predict_samples', $params);
return $predictedrange;
}
/**
* Saves samples that have just been used for training.
*
* @param int[] $sampleids
* @param \core_analytics\local\time_splitting\base $timesplitting
* @return null
*/
protected function save_train_samples(array $sampleids, \core_analytics\local\time_splitting\base $timesplitting) {
global $DB;
$trainingsamples = new \stdClass();
$trainingsamples->modelid = $this->analyser->get_modelid();
$trainingsamples->analysableid = $timesplitting->get_analysable()->get_id();
$trainingsamples->timesplitting = $timesplitting->get_id();
$trainingsamples->sampleids = json_encode($sampleids);
$trainingsamples->timecreated = time();
$DB->insert_record('analytics_train_samples', $trainingsamples);
}
/**
* Saves samples that have just been used for prediction.
*
* @param int[] $sampleids
* @param array $ranges
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \stdClass|null $predictsamplesrecord The existing record or null if there is no record yet.
* @return null
*/
protected function save_prediction_samples(array $sampleids, array $ranges,
\core_analytics\local\time_splitting\base $timesplitting, ?\stdClass $predictsamplesrecord = null) {
global $DB;
if (count($ranges) > 1) {
throw new \coding_exception('$ranges argument should only contain one range');
}
$rangeindex = key($ranges);
if ($predictsamplesrecord) {
// Append the new samples used for prediction.
$predictsamplesrecord->sampleids = json_encode($predictsamplesrecord->sampleids + $sampleids);
$predictsamplesrecord->timemodified = time();
$DB->update_record('analytics_predict_samples', $predictsamplesrecord);
} else {
$predictsamplesrecord = (object)[
'modelid' => $this->analyser->get_modelid(),
'analysableid' => $timesplitting->get_analysable()->get_id(),
'timesplitting' => $timesplitting->get_id(), 'rangeindex' => $rangeindex
];
$predictsamplesrecord->sampleids = json_encode($sampleids);
$predictsamplesrecord->timecreated = time();
$predictsamplesrecord->timemodified = $predictsamplesrecord->timecreated;
$DB->insert_record('analytics_predict_samples', $predictsamplesrecord);
}
}
/**
* Flags the analysable element as in-analysis and stores a lock for it.
*
* @param string $timesplittingid
* @param int $analysableid
* @return bool Success or not
*/
private function init_analysable_analysis(string $timesplittingid, int $analysableid) {
// Do not include $this->includetarget as we don't want the same analysable to be analysed for training
// and prediction at the same time.
$lockkey = 'modelid:' . $this->analyser->get_modelid() . '-analysableid:' . $analysableid .
'-timesplitting:' . self::clean_time_splitting_id($timesplittingid);
// Large timeout as processes may be quite long.
$lockfactory = \core\lock\lock_config::get_lock_factory('core_analytics');
// 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;
}
/**
* Remove all possibly problematic chars from the time splitting method id (id = its full class name).
*
* @param string $timesplittingid
* @return string
*/
public static function clean_time_splitting_id($timesplittingid) {
$timesplittingid = str_replace('\\', '-', $timesplittingid);
return clean_param($timesplittingid, PARAM_ALPHANUMEXT);
}
/**
* Mark the currently analysed analysable+timesplitting as analysed.
*
* @return null
*/
private function finish_analysable_analysis() {
$this->lock->release();
}
/**
* Returns the batch size used for insert_records.
*
* This method tries to find the best batch size without getting
* into dml internals. Maximum 1000 records to save memory.
*
* @return int
*/
private static function get_insert_batch_size(): int {
global $DB;
$dbconfig = $DB->export_dbconfig();
// 500 is pgsql default so using 1000 is fine, no other db driver uses a hardcoded value.
if (empty($dbconfig) || empty($dbconfig->dboptions) || empty($dbconfig->dboptions['bulkinsertsize'])) {
return 1000;
}
$bulkinsert = $dbconfig->dboptions['bulkinsertsize'];
if ($bulkinsert < 1000) {
return $bulkinsert;
}
while ($bulkinsert > 1000) {
$bulkinsert = round($bulkinsert / 2, 0);
}
return (int)$bulkinsert;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?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/>.
/**
* Representation of a suggested bulk action.
*
* @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();
/**
* Representation of a suggested bulk action.
*
* @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 bulk_action extends action {
/**
* Prediction action constructor.
*
* @param string $actionname They should match a-zA-Z_0-9-, as we apply a PARAM_ALPHANUMEXT filter
* @param \moodle_url $actionurl The final URL where the user should be forwarded.
* @param \pix_icon $icon Link icon
* @param string $text Link text
* @param bool $primary Primary button or secondary.
* @param array $attributes Link attributes
* @param string|false $type
* @return void
*/
public function __construct($actionname, \moodle_url $actionurl, \pix_icon $icon,
$text, $primary = false, $attributes = array(), $type = false) {
global $OUTPUT;
$this->actionname = $actionname;
$this->text = $text;
$this->set_type($type);
// 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, 'forwardurl' => $actionurl->out(false));
$this->url = new \moodle_url('/report/insights/action.php', $params);
$label = $OUTPUT->render($icon) . $this->text;
$this->actionlink = new \single_button($this->url, $label,
'get',
$primary ? \single_button::BUTTON_PRIMARY : \single_button::BUTTON_SECONDARY,
$attributes);
}
}
+326
View File
@@ -0,0 +1,326 @@
<?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/>.
/**
* Calculable dataset items abstract class.
*
* @package core_analytics
* @copyright 2016 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();
/**
* Calculable dataset items abstract class.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class calculable {
/**
* Neutral calculation outcome.
*/
const OUTCOME_NEUTRAL = 0;
/**
* Very positive calculation outcome.
*/
const OUTCOME_VERY_POSITIVE = 1;
/**
* Positive calculation outcome.
*/
const OUTCOME_OK = 2;
/**
* Negative calculation outcome.
*/
const OUTCOME_NEGATIVE = 3;
/**
* Very negative calculation outcome.
*/
const OUTCOME_VERY_NEGATIVE = 4;
/**
* @var array[]
*/
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.
*
* Used as column identificator.
*
* If there is a corresponding '_help' string this will be shown as well.
*
* @return \lang_string
*/
abstract public static function get_name(): \lang_string;
/**
* The class id is the calculable class full qualified class name.
*
* @return string
*/
public function get_id() {
// Using get_class as get_component_classes_in_namespace returns double escaped fully qualified class names.
return '\\' . get_class($this);
}
/**
* add_sample_data
*
* @param array $data
* @return void
*/
public function add_sample_data($data) {
$this->sampledata = $this->array_merge_recursive_keep_keys($this->sampledata, $data);
}
/**
* clear_sample_data
*
* @return void
*/
public function clear_sample_data() {
$this->sampledata = array();
}
/**
* Returns the visible value of the calculated value.
*
* @param float $value
* @param string|false $subtype
* @return string
*/
public function get_display_value($value, $subtype = false) {
return $value;
}
/**
* Returns how good the calculated value is.
*
* Use one of \core_analytics\calculable::OUTCOME_* values.
*
* @param float $value
* @param string|false $subtype
* @return int
*/
abstract public function get_calculation_outcome($value, $subtype = false);
/**
* Retrieve the specified element associated to $sampleid.
*
* @param string $elementname
* @param int $sampleid
* @return \stdClass|false An \stdClass object or false if it can not be found.
*/
protected function retrieve($elementname, $sampleid) {
if (empty($this->sampledata[$sampleid]) || empty($this->sampledata[$sampleid][$elementname])) {
// We don't throw an exception because indicators should be able to
// try multiple tables until they find something they can use.
return false;
}
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
*/
final protected 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
*/
final public 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.
*
* 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
* @return float
*/
protected function get_time_range_weeks_number($starttime, $endtime) {
if ($endtime <= $starttime) {
throw new \coding_exception('End time timestamp should be greater than start time.');
}
$starttimedt = new \DateTime();
$starttimedt->setTimestamp($starttime);
$starttimedt->setTimezone(new \DateTimeZone('UTC'));
$endtimedt = new \DateTime();
$endtimedt->setTimestamp($endtime);
$endtimedt->setTimezone(new \DateTimeZone('UTC'));
$diff = $endtimedt->getTimestamp() - $starttimedt->getTimestamp();
return $diff / WEEKSECS;
}
/**
* Limits the calculated value to the minimum and maximum values.
*
* @param float $calculatedvalue
* @return float|null
*/
protected function limit_value($calculatedvalue) {
return max(min($calculatedvalue, static::get_max_value()), static::get_min_value());
}
/**
* Classifies the provided value into the provided range according to the ranges predicates.
*
* Use:
* - eq as 'equal'
* - ne as 'not equal'
* - lt as 'lower than'
* - le as 'lower or equal than'
* - gt as 'greater than'
* - ge as 'greater or equal than'
*
* @throws \coding_exception
* @param int|float $value
* @param array $ranges e.g. [ ['lt', 20], ['ge', 20] ]
* @return float
*/
protected function classify_value($value, $ranges) {
// To automatically return calculated values from min to max values.
$rangeweight = (static::get_max_value() - static::get_min_value()) / (count($ranges) - 1);
foreach ($ranges as $key => $range) {
$match = false;
if (count($range) != 2) {
throw new \coding_exception('classify_value() $ranges array param should contain 2 items, the predicate ' .
'e.g. greater (gt), lower or equal (le)... and the value.');
}
list($predicate, $rangevalue) = $range;
switch ($predicate) {
case 'eq':
if ($value == $rangevalue) {
$match = true;
}
break;
case 'ne':
if ($value != $rangevalue) {
$match = true;
}
break;
case 'lt':
if ($value < $rangevalue) {
$match = true;
}
break;
case 'le':
if ($value <= $rangevalue) {
$match = true;
}
break;
case 'gt':
if ($value > $rangevalue) {
$match = true;
}
break;
case 'ge':
if ($value >= $rangevalue) {
$match = true;
}
break;
default:
throw new \coding_exception('Unrecognised predicate ' . $predicate . '. Please use eq, ne, lt, le, ge or gt.');
}
// Calculate and return a linear calculated value for the provided value.
if ($match) {
return round(static::get_min_value() + ($rangeweight * $key), 2);
}
}
throw new \coding_exception('The provided value "' . $value . '" can not be fit into any of the provided ranges, you ' .
'should provide ranges for all possible values.');
}
/**
* Merges arrays recursively keeping the same keys the original arrays have.
*
* @link http://php.net/manual/es/function.array-merge-recursive.php#114818
* @return array
*/
private function array_merge_recursive_keep_keys() {
$arrays = func_get_args();
$base = array_shift($arrays);
foreach ($arrays as $array) {
reset($base);
foreach ($array as $key => $value) {
if (is_array($value) && !empty($base[$key]) && is_array($base[$key])) {
$base[$key] = $this->array_merge_recursive_keep_keys($base[$key], $value);
} else {
if (isset($base[$key]) && is_int($key)) {
$key++;
}
$base[$key] = $value;
}
}
}
return $base;
}
}
+184
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;
}
}
+71
View File
@@ -0,0 +1,71 @@
<?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/>.
/**
* Classifier interface.
*
* @package core_analytics
* @copyright 2017 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();
/**
* Classifier interface.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
interface classifier extends predictor {
/**
* Train this processor classification model using the provided supervised learning dataset.
*
* @param string $uniqueid
* @param \stored_file $dataset
* @param string $outputdir
* @return \stdClass
*/
public function train_classification($uniqueid, \stored_file $dataset, $outputdir);
/**
* Classifies the provided dataset samples.
*
* @param string $uniqueid
* @param \stored_file $dataset
* @param string $outputdir
* @return \stdClass
*/
public function classify($uniqueid, \stored_file $dataset, $outputdir);
/**
* Evaluates this processor classification model using the provided supervised learning dataset.
*
* @param string $uniqueid
* @param float $maxdeviation
* @param int $niterations
* @param \stored_file $dataset
* @param string $outputdir
* @param string $trainedmodeldir
* @return \stdClass
*/
public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
$outputdir, $trainedmodeldir);
}
+628
View File
@@ -0,0 +1,628 @@
<?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/>.
/**
* Moodle course analysable
*
* @package core_analytics
* @copyright 2016 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();
require_once($CFG->dirroot . '/course/lib.php');
require_once($CFG->dirroot . '/lib/gradelib.php');
require_once($CFG->dirroot . '/lib/enrollib.php');
/**
* Moodle course analysable
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class course implements \core_analytics\analysable {
/**
* @var bool Has this course data been already loaded.
*/
protected $loaded = false;
/**
* @var int $cachedid self::$cachedinstance analysable id.
*/
protected static $cachedid = 0;
/**
* @var \core_analytics\course $cachedinstance
*/
protected static $cachedinstance = null;
/**
* Course object
*
* @var \stdClass
*/
protected $course = null;
/**
* The course context.
*
* @var \context_course
*/
protected $coursecontext = null;
/**
* The course activities organized by activity type.
*
* @var array
*/
protected $courseactivities = array();
/**
* Course start time.
*
* @var int
*/
protected $starttime = null;
/**
* Has the course already started?
*
* @var bool
*/
protected $started = null;
/**
* Course end time.
*
* @var int
*/
protected $endtime = null;
/**
* Is the course finished?
*
* @var bool
*/
protected $finished = null;
/**
* Course students ids.
*
* @var int[]
*/
protected $studentids = [];
/**
* Course teachers ids
*
* @var int[]
*/
protected $teacherids = [];
/**
* Cached copy of the total number of logs in the course.
*
* @var int
*/
protected $ntotallogs = null;
/** @var int Store current Unix timestamp. */
protected int $now = 0;
/**
* Course manager constructor.
*
* Use self::instance() instead to get cached copies of the course. Instances obtained
* through this constructor will not be cached.
*
* @param int|\stdClass $course Course id or mdl_course record
* @param \context|null $context
* @return void
*/
public function __construct($course, ?\context $context = null) {
if (is_scalar($course)) {
$this->course = new \stdClass();
$this->course->id = $course;
} else {
$this->course = $course;
}
if (!is_null($context)) {
$this->coursecontext = $context;
}
}
/**
* Returns an analytics course instance.
*
* Lazy load of course data, students and teachers.
*
* @param int|\stdClass $course Course object or course id
* @param \context|null $context
* @return \core_analytics\course
*/
public static function instance($course, ?\context $context = null) {
$courseid = $course;
if (!is_scalar($courseid)) {
$courseid = $course->id;
}
if (self::$cachedid === $courseid) {
return self::$cachedinstance;
}
$cachedinstance = new \core_analytics\course($course, $context);
self::$cachedinstance = $cachedinstance;
self::$cachedid = (int)$courseid;
return self::$cachedinstance;
}
/**
* get_id
*
* @return int
*/
public function get_id() {
return $this->course->id;
}
/**
* Loads the analytics course object.
*
* @return void
*/
protected function load() {
// The instance constructor could be already loaded with the full course object. Using shortname
// because it is a required course field.
if (empty($this->course->shortname)) {
$this->course = get_course($this->course->id);
}
$this->coursecontext = $this->get_context();
$this->now = time();
// Get the course users, including users assigned to student and teacher roles at an higher context.
$cache = \cache::make_from_params(\cache_store::MODE_REQUEST, 'core_analytics', 'rolearchetypes');
// Flag the instance as loaded.
$this->loaded = true;
if (!$studentroles = $cache->get('student')) {
$studentroles = array_keys(get_archetype_roles('student'));
$cache->set('student', $studentroles);
}
$this->studentids = $this->get_user_ids($studentroles);
if (!$teacherroles = $cache->get('teacher')) {
$teacherroles = array_keys(get_archetype_roles('editingteacher') + get_archetype_roles('teacher'));
$cache->set('teacher', $teacherroles);
}
$this->teacherids = $this->get_user_ids($teacherroles);
}
/**
* The course short name
*
* @return string
*/
public function get_name() {
return format_string($this->get_course_data()->shortname, true, array('context' => $this->get_context()));
}
/**
* get_context
*
* @return \context
*/
public function get_context() {
if ($this->coursecontext === null) {
$this->coursecontext = \context_course::instance($this->course->id);
}
return $this->coursecontext;
}
/**
* Get the course start timestamp.
*
* @return int Timestamp or 0 if has not started yet.
*/
public function get_start() {
if ($this->starttime !== null) {
return $this->starttime;
}
// The field always exist but may have no valid if the course is created through a sync process.
if (!empty($this->get_course_data()->startdate)) {
$this->starttime = (int)$this->get_course_data()->startdate;
} else {
$this->starttime = 0;
}
return $this->starttime;
}
/**
* Guesses the start of the course based on students' activity and enrolment start dates.
*
* @return int
*/
public function guess_start() {
global $DB;
if (!$this->get_total_logs()) {
// Can't guess.
return 0;
}
if (!$logstore = \core_analytics\manager::get_analytics_logstore()) {
return 0;
}
// We first try to find current course student logs.
$firstlogs = array();
foreach ($this->get_students() as $studentid) {
// Grrr, we are limited by logging API, we could do this easily with a
// select min(timecreated) from xx where courseid = yy group by userid.
// Filters based on the premise that more than 90% of people will be using
// standard logstore, which contains a userid, contextlevel, contextinstanceid index.
$select = "userid = :userid AND contextlevel = :contextlevel AND contextinstanceid = :contextinstanceid";
$params = array('userid' => $studentid, 'contextlevel' => CONTEXT_COURSE, 'contextinstanceid' => $this->get_id());
$events = $logstore->get_events_select($select, $params, 'timecreated ASC', 0, 1);
if ($events) {
$event = reset($events);
$firstlogs[] = $event->timecreated;
}
}
if (empty($firstlogs)) {
// Can't guess if no student accesses.
return 0;
}
sort($firstlogs);
$firstlogsmedian = $this->median($firstlogs);
$studentenrolments = enrol_get_course_users($this->get_id(), $this->get_students());
if (empty($studentenrolments)) {
return 0;
}
$enrolstart = array();
foreach ($studentenrolments as $studentenrolment) {
$enrolstart[] = ($studentenrolment->uetimestart) ? $studentenrolment->uetimestart : $studentenrolment->uetimecreated;
}
sort($enrolstart);
$enrolstartmedian = $this->median($enrolstart);
return intval(($enrolstartmedian + $firstlogsmedian) / 2);
}
/**
* Get the course end timestamp.
*
* @return int Timestamp or 0 if time end was not set.
*/
public function get_end() {
global $DB;
if ($this->endtime !== null) {
return $this->endtime;
}
// The enddate field is only available from Moodle 3.2 (MDL-22078).
if (!empty($this->get_course_data()->enddate)) {
$this->endtime = (int)$this->get_course_data()->enddate;
return $this->endtime;
}
return 0;
}
/**
* Get the course end timestamp.
*
* @return int Timestamp, \core_analytics\analysable::MAX_TIME if we don't know but ongoing and 0 if we can not work it out.
*/
public function guess_end() {
global $DB;
if ($this->get_total_logs() === 0) {
// No way to guess if there are no logs.
$this->endtime = 0;
return $this->endtime;
}
list($filterselect, $filterparams) = $this->course_students_query_filter('ula');
// Consider the course open if there are still student accesses.
$monthsago = time() - (WEEKSECS * 4 * 2);
$select = $filterselect . ' AND timeaccess > :timeaccess';
$params = $filterparams + array('timeaccess' => $monthsago);
$sql = "SELECT DISTINCT timeaccess FROM {user_lastaccess} ula
JOIN {enrol} e ON e.courseid = ula.courseid
JOIN {user_enrolments} ue ON e.id = ue.enrolid AND ue.userid = ula.userid
WHERE $select";
if ($records = $DB->get_records_sql($sql, $params)) {
return 0;
}
$sql = "SELECT DISTINCT timeaccess FROM {user_lastaccess} ula
JOIN {enrol} e ON e.courseid = ula.courseid
JOIN {user_enrolments} ue ON e.id = ue.enrolid AND ue.userid = ula.userid
WHERE $filterselect AND ula.timeaccess != 0
ORDER BY timeaccess DESC";
$studentlastaccesses = $DB->get_fieldset_sql($sql, $filterparams);
if (empty($studentlastaccesses)) {
return 0;
}
sort($studentlastaccesses);
return $this->median($studentlastaccesses);
}
/**
* Returns a course plain object.
*
* @return \stdClass
*/
public function get_course_data() {
if (!$this->loaded) {
$this->load();
}
return $this->course;
}
/**
* Has the course started?
*
* @return bool
*/
public function was_started() {
if ($this->started === null) {
if ($this->get_start() === 0 || $this->now < $this->get_start()) {
// Not yet started.
$this->started = false;
} else {
$this->started = true;
}
}
return $this->started;
}
/**
* Has the course finished?
*
* @return bool
*/
public function is_finished() {
if ($this->finished === null) {
$endtime = $this->get_end();
if ($endtime === 0 || $this->now < $endtime) {
// It is not yet finished or no idea when it finishes.
$this->finished = false;
} else {
$this->finished = true;
}
}
return $this->finished;
}
/**
* Returns a list of user ids matching the specified roles in this course.
*
* @param array $roleids
* @return array
*/
public function get_user_ids($roleids) {
// We need to index by ra.id as a user may have more than 1 $roles role.
$records = get_role_users($roleids, $this->get_context(), true, 'ra.id, u.id AS userid, r.id AS roleid', 'ra.id ASC');
// If a user have more than 1 $roles role array_combine will discard the duplicate.
$callable = array($this, 'filter_user_id');
$userids = array_values(array_map($callable, $records));
return array_combine($userids, $userids);
}
/**
* Returns the course students.
*
* @return int[]
*/
public function get_students() {
if (!$this->loaded) {
$this->load();
}
return $this->studentids;
}
/**
* Returns the total number of student logs in the course
*
* @return int
*/
public function get_total_logs() {
global $DB;
// No logs if no students.
if (empty($this->get_students())) {
return 0;
}
if ($this->ntotallogs === null) {
list($filterselect, $filterparams) = $this->course_students_query_filter();
if (!$logstore = \core_analytics\manager::get_analytics_logstore()) {
$this->ntotallogs = 0;
} else {
$this->ntotallogs = $logstore->get_events_select_count($filterselect, $filterparams);
}
}
return $this->ntotallogs;
}
/**
* Returns all the activities of the provided type the course has.
*
* @param string $activitytype
* @return array
*/
public function get_all_activities($activitytype) {
// Using is set because we set it to false if there are no activities.
if (!isset($this->courseactivities[$activitytype])) {
$modinfo = get_fast_modinfo($this->get_course_data(), -1);
$instances = $modinfo->get_instances_of($activitytype);
if ($instances) {
$this->courseactivities[$activitytype] = array();
foreach ($instances as $instance) {
// By context.
$this->courseactivities[$activitytype][$instance->context->id] = $instance;
}
} else {
$this->courseactivities[$activitytype] = false;
}
}
return $this->courseactivities[$activitytype];
}
/**
* Returns the course students grades.
*
* @param array $courseactivities
* @return array
*/
public function get_student_grades($courseactivities) {
if (empty($courseactivities)) {
return array();
}
$grades = array();
foreach ($courseactivities as $contextid => $instance) {
$gradesinfo = grade_get_grades($this->course->id, 'mod', $instance->modname, $instance->instance, $this->studentids);
// Sort them by activity context and user.
if ($gradesinfo && $gradesinfo->items) {
foreach ($gradesinfo->items as $gradeitem) {
foreach ($gradeitem->grades as $userid => $grade) {
if (empty($grades[$contextid][$userid])) {
// Initialise it as array because a single activity can have multiple grade items (e.g. workshop).
$grades[$contextid][$userid] = array();
}
$grades[$contextid][$userid][$gradeitem->id] = $grade;
}
}
}
}
return $grades;
}
/**
* Used by get_user_ids to extract the user id.
*
* @param \stdClass $record
* @return int The user id.
*/
protected function filter_user_id($record) {
return $record->userid;
}
/**
* Returns the average time between 2 timestamps.
*
* @param int $start
* @param int $end
* @return array [starttime, averagetime, endtime]
*/
protected function update_loop_times($start, $end) {
$avg = intval(($start + $end) / 2);
return array($start, $avg, $end);
}
/**
* Returns the query and params used to filter the logstore by this course students.
*
* @param string $prefix
* @return array
*/
protected function course_students_query_filter($prefix = false) {
global $DB;
if ($prefix) {
$prefix = $prefix . '.';
}
// Check the amount of student logs in the 4 previous weeks.
list($studentssql, $studentsparams) = $DB->get_in_or_equal($this->get_students(), SQL_PARAMS_NAMED);
$filterselect = $prefix . 'courseid = :courseid AND ' . $prefix . 'userid ' . $studentssql;
$filterparams = array('courseid' => $this->course->id) + $studentsparams;
return array($filterselect, $filterparams);
}
/**
* Calculate median
*
* Keys are ignored.
*
* @param int[]|float[] $values Sorted array of values
* @return int
*/
protected function median($values) {
$count = count($values);
if ($count === 1) {
return reset($values);
}
$middlevalue = (int)floor(($count - 1) / 2);
if ($count % 2) {
// Odd number, middle is the median.
$median = $values[$middlevalue];
} else {
// Even number, calculate avg of 2 medians.
$low = $values[$middlevalue];
$high = $values[$middlevalue + 1];
$median = (($low + $high) / 2);
}
return intval($median);
}
}
+457
View File
@@ -0,0 +1,457 @@
<?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/>.
/**
* Datasets manager.
*
* @package core_analytics
* @copyright 2016 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();
/**
* Datasets manager.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class dataset_manager {
/**
* File area for labelled datasets.
*/
const LABELLED_FILEAREA = 'labelled';
/**
* File area for unlabelled datasets.
*/
const UNLABELLED_FILEAREA = 'unlabelled';
/**
* File area for exported datasets.
*/
const EXPORT_FILEAREA = 'export';
/**
* Evaluation file file name.
*/
const EVALUATION_FILENAME = 'evaluation.csv';
/**
* The model id.
*
* @var int
*/
protected $modelid;
/**
* Range processor in use.
*
* @var string
*/
protected $timesplittingid;
/**
* @var int
*/
protected $analysableid;
/**
* Whether this is a dataset for evaluation or not.
*
* @var bool
*/
protected $evaluation;
/**
* The dataset filearea. Must be one of the self::*_FILEAREA options.
*
* @var string
*/
protected $filearea;
/**
* Constructor method.
*
* @throws \coding_exception
* @param int $modelid
* @param int $analysableid
* @param string $timesplittingid
* @param string $filearea
* @param bool $evaluation
* @return void
*/
public function __construct($modelid, $analysableid, $timesplittingid, $filearea, $evaluation = false) {
if ($filearea !== self::EXPORT_FILEAREA && $filearea !== self::LABELLED_FILEAREA &&
$filearea !== self::UNLABELLED_FILEAREA) {
throw new \coding_exception('Invalid provided filearea');
}
$this->modelid = $modelid;
$this->analysableid = $analysableid;
$this->timesplittingid = $timesplittingid;
$this->filearea = $filearea;
$this->evaluation = $evaluation;
}
/**
* Store the dataset in the internal file system.
*
* @param array $data
* @return \stored_file
*/
public function store($data) {
// Delete previous file if it exists.
$fs = get_file_storage();
$filerecord = [
'component' => 'analytics',
'filearea' => $this->filearea,
'itemid' => $this->modelid,
'contextid' => \context_system::instance()->id,
'filepath' => '/analysable/' . $this->analysableid . '/' .
\core_analytics\analysis::clean_time_splitting_id($this->timesplittingid) . '/',
'filename' => self::get_filename($this->evaluation)
];
// Delete previous and old (we already checked that previous copies are not recent) evaluation files for this analysable.
if ($this->evaluation) {
$select = " = {$filerecord['itemid']} AND filepath = :filepath";
$fs->delete_area_files_select($filerecord['contextid'], $filerecord['component'], $filerecord['filearea'],
$select, array('filepath' => $filerecord['filepath']));
}
// Write all this stuff to a tmp file.
$filepath = make_request_directory() . DIRECTORY_SEPARATOR . $filerecord['filename'];
$fh = fopen($filepath, 'w+');
if (!$fh) {
return false;
}
foreach ($data as $line) {
fputcsv($fh, $line);
}
fclose($fh);
return $fs->create_file_from_pathname($filerecord, $filepath);
}
/**
* Returns the previous evaluation file.
*
* Important to note that this is per modelid + timesplittingid, when dealing with multiple
* analysables this is the merged file. Do not confuse with self::get_evaluation_analysable_file
*
* @param int $modelid
* @param string $timesplittingid
* @return \stored_file
*/
public static function get_previous_evaluation_file($modelid, $timesplittingid) {
$fs = get_file_storage();
// Evaluation data is always labelled.
$filepath = '/timesplitting/' . \core_analytics\analysis::clean_time_splitting_id($timesplittingid) . '/';
return $fs->get_file(\context_system::instance()->id, 'analytics', self::LABELLED_FILEAREA, $modelid,
$filepath, self::EVALUATION_FILENAME);
}
/**
* Gets the list of files that couldn't be previously used for training and prediction.
*
* @param int $modelid
* @param bool $includetarget
* @param string[] $timesplittingids
* @return null
*/
public static function get_pending_files($modelid, $includetarget, $timesplittingids) {
global $DB;
$fs = get_file_storage();
if ($includetarget) {
$filearea = self::LABELLED_FILEAREA;
$usedfileaction = 'trained';
} else {
$filearea = self::UNLABELLED_FILEAREA;
$usedfileaction = 'predicted';
}
$select = 'modelid = :modelid AND action = :action';
$params = array('modelid' => $modelid, 'action' => $usedfileaction);
$usedfileids = $DB->get_fieldset_select('analytics_used_files', 'fileid', $select, $params);
// Very likely that we will only have 1 time splitting method here.
$filesbytimesplitting = array();
foreach ($timesplittingids as $timesplittingid) {
$filepath = '/timesplitting/' . \core_analytics\analysis::clean_time_splitting_id($timesplittingid) . '/';
$files = $fs->get_directory_files(\context_system::instance()->id, 'analytics', $filearea, $modelid, $filepath);
foreach ($files as $file) {
// Discard evaluation files.
if ($file->get_filename() === self::EVALUATION_FILENAME) {
continue;
}
// No dirs.
if ($file->is_directory()) {
continue;
}
// Already used for training.
if (in_array($file->get_id(), $usedfileids)) {
continue;
}
$filesbytimesplitting[$timesplittingid][] = $file;
}
}
return $filesbytimesplitting;
}
/**
* Deletes previous evaluation files of this model.
*
* @param int $modelid
* @param string $timesplittingid
* @return bool
*/
public static function delete_previous_evaluation_file($modelid, $timesplittingid) {
if ($file = self::get_previous_evaluation_file($modelid, $timesplittingid)) {
$file->delete();
return true;
}
return false;
}
/**
* Returns this (model + analysable + time splitting) file.
*
* @param int $modelid
* @param int $analysableid
* @param string $timesplittingid
* @return \stored_file
*/
public static function get_evaluation_analysable_file($modelid, $analysableid, $timesplittingid) {
// Delete previous file if it exists.
$fs = get_file_storage();
// Always evaluation.csv and labelled as it is an evaluation file.
$filearea = self::LABELLED_FILEAREA;
$filename = self::get_filename(true);
$filepath = '/analysable/' . $analysableid . '/' .
\core_analytics\analysis::clean_time_splitting_id($timesplittingid) . '/';
return $fs->get_file(\context_system::instance()->id, 'analytics', $filearea, $modelid, $filepath, $filename);
}
/**
* Merge multiple files into one.
*
* Important! It is the caller responsability to ensure that the datasets are compatible.
*
* @param array $files
* @param int $modelid
* @param string $timesplittingid
* @param string $filearea
* @param bool $evaluation
* @return \stored_file
*/
public static function merge_datasets(array $files, $modelid, $timesplittingid, $filearea, $evaluation = false) {
$tmpfilepath = make_request_directory() . DIRECTORY_SEPARATOR . 'tmpfile.csv';
// Add headers.
// We could also do this with a single iteration gathering all files headers and appending them to the beginning of the file
// once all file contents are merged.
$varnames = '';
$analysablesvalues = array();
foreach ($files as $file) {
$rh = $file->get_content_file_handle();
// Copy the var names as they are, all files should have the same var names.
$varnames = fgetcsv($rh);
$analysablesvalues[] = fgetcsv($rh);
// Copy the columns as they are, all files should have the same columns.
$columns = fgetcsv($rh);
}
// Merge analysable values skipping the ones that are the same in all analysables.
$values = array();
foreach ($analysablesvalues as $analysablevalues) {
foreach ($analysablevalues as $varkey => $value) {
// Sha1 to make it unique.
$values[$varkey][sha1($value)] = $value;
}
}
foreach ($values as $varkey => $varvalues) {
$values[$varkey] = implode('|', $varvalues);
}
// 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);
fputcsv($wh, $columns);
// Iterate through all files and add them to the tmp one. We don't want file contents in memory.
foreach ($files as $file) {
$rh = $file->get_content_file_handle();
// Skip headers.
fgets($rh);
fgets($rh);
fgets($rh);
// Copy all the following lines.
while ($line = fgets($rh)) {
fwrite($wh, $line);
}
fclose($rh);
}
fclose($wh);
$filerecord = [
'component' => 'analytics',
'filearea' => $filearea,
'itemid' => $modelid,
'contextid' => \context_system::instance()->id,
'filepath' => '/timesplitting/' . \core_analytics\analysis::clean_time_splitting_id($timesplittingid) . '/',
'filename' => self::get_filename($evaluation)
];
$fs = get_file_storage();
return $fs->create_file_from_pathname($filerecord, $tmpfilepath);
}
/**
* Exports the model training data.
*
* @param int $modelid
* @param string $timesplittingid
* @return \stored_file|false
*/
public static function export_training_data($modelid, $timesplittingid) {
$fs = get_file_storage();
$contextid = \context_system::instance()->id;
$filepath = '/timesplitting/' . \core_analytics\analysis::clean_time_splitting_id($timesplittingid) . '/';
$files = $fs->get_directory_files($contextid, 'analytics', self::LABELLED_FILEAREA, $modelid,
$filepath, true, false);
// Discard evaluation files.
foreach ($files as $key => $file) {
if ($file->get_filename() === self::EVALUATION_FILENAME) {
unset($files[$key]);
}
}
if (empty($files)) {
return false;
}
return self::merge_datasets($files, $modelid, $timesplittingid, self::EXPORT_FILEAREA);
}
/**
* Returns the dataset file data structured by sampleids using the indicators and target column names.
*
* @param \stored_file $dataset
* @return array
*/
public static function get_structured_data(\stored_file $dataset) {
if ($dataset->get_filearea() !== 'unlabelled') {
throw new \coding_exception('Sorry, only support for unlabelled data');
}
$rh = $dataset->get_content_file_handle();
// Skip dataset info.
fgets($rh);
fgets($rh);
$calculations = array();
$headers = fgetcsv($rh);
// Get rid of the sampleid column name.
array_shift($headers);
while ($columns = fgetcsv($rh)) {
$uniquesampleid = array_shift($columns);
// Unfortunately fgetcsv does not respect line's var types.
$calculations[$uniquesampleid] = array_map(function($value) {
if ($value === '') {
// We really want them as null because converted to float become 0
// and we need to treat the values separately.
return null;
} else if (is_numeric($value)) {
return floatval($value);
}
return $value;
}, array_combine($headers, $columns));
}
return $calculations;
}
/**
* Delete all files of a model.
*
* @param int $modelid
* @return bool
*/
public static function clear_model_files($modelid) {
$fs = get_file_storage();
return $fs->delete_area_files(\context_system::instance()->id, 'analytics', false, $modelid);
}
/**
* Returns the file name to be used.
*
* @param strinbool $evaluation
* @return string
*/
protected static function get_filename($evaluation) {
if ($evaluation === true) {
$filename = self::EVALUATION_FILENAME;
} else {
// Incremental time, the lock will make sure we don't have concurrency problems.
$filename = microtime(true) . '.csv';
}
return $filename;
}
}
+126
View File
@@ -0,0 +1,126 @@
<?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/>.
/**
* Default list of bulk actions to reuse across different targets as presets.
*
* @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();
/**
* Default list of bulk actions to reuse across different targets as presets.
*
* @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 default_bulk_actions {
/**
* Accepted prediction.
*
* @return \core_analytics\bulk_action
*/
public static function accept() {
$attrs = [
'data-bulk-actionname' => prediction::ACTION_FIXED
] + self::bulk_action_base_attrs();
return new bulk_action(prediction::ACTION_FIXED,
new \moodle_url(''), new \pix_icon('t/check', get_string('fixedack', 'analytics')),
get_string('fixedack', 'analytics'), false, $attrs, action::TYPE_POSITIVE);
}
/**
* The prediction is not applicable for this same (e.g. This student was unenrolled in the uni SIS).
*
* @return \core_analytics\bulk_action
*/
public static function not_applicable() {
$attrs = [
'data-bulk-actionname' => prediction::ACTION_NOT_APPLICABLE
] + self::bulk_action_base_attrs();
return new bulk_action(prediction::ACTION_NOT_APPLICABLE,
new \moodle_url(''), new \pix_icon('fp/cross', get_string('notapplicable', 'analytics'), 'theme'),
get_string('notapplicable', 'analytics'), false, $attrs, action::TYPE_NEUTRAL);
}
/**
* Incorrectly flagged prediction, useful for models based on data.
*
* @return \core_analytics\bulk_action
*/
public static function incorrectly_flagged() {
$attrs = [
'data-bulk-actionname' => prediction::ACTION_INCORRECTLY_FLAGGED
] + self::bulk_action_base_attrs();
return new bulk_action(prediction::ACTION_INCORRECTLY_FLAGGED,
new \moodle_url(''), new \pix_icon('i/incorrect', get_string('incorrectlyflagged', 'analytics')),
get_string('incorrectlyflagged', 'analytics'), false, $attrs, action::TYPE_NEGATIVE);
}
/**
* Useful prediction.
*
* @return \core_analytics\bulk_action
*/
public static function useful() {
$attrs = [
'data-bulk-actionname' => prediction::ACTION_USEFUL
] + self::bulk_action_base_attrs();
return new bulk_action(prediction::ACTION_USEFUL,
new \moodle_url(''), new \pix_icon('t/check', get_string('useful', 'analytics')),
get_string('useful', 'analytics'), false, $attrs, action::TYPE_POSITIVE);
}
/**
* Not useful prediction.
*
* @return \core_analytics\bulk_action
*/
public static function not_useful() {
$attrs = [
'data-bulk-actionname' => prediction::ACTION_NOT_USEFUL
] + self::bulk_action_base_attrs();
return new bulk_action(prediction::ACTION_NOT_USEFUL,
new \moodle_url(''), new \pix_icon('t/delete', get_string('notuseful', 'analytics')),
get_string('notuseful', 'analytics'), false, $attrs, action::TYPE_NEGATIVE);
}
/**
* Common attributes for all the action renderables.
*
* @return array
*/
private static function bulk_action_base_attrs() {
return [
'disabled' => 'disabled',
'data-toggle' => 'action',
'data-action' => 'toggle',
];
}
}
+275
View File
@@ -0,0 +1,275 @@
<?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/>.
/**
* Insights generator.
*
* @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();
require_once($CFG->dirroot . '/lib/messagelib.php');
/**
* Insights generator.
*
* @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 insights_generator {
/**
* @var int
*/
private $modelid;
/**
* @var \core_analytics\local\target\base
*/
private $target;
/**
* @var int[]
*/
private $contextcourseids;
/**
* Constructor.
*
* @param int $modelid
* @param \core_analytics\local\target\base $target
*/
public function __construct(int $modelid, \core_analytics\local\target\base $target) {
$this->modelid = $modelid;
$this->target = $target;
}
/**
* Generates insight notifications.
*
* @param array $samplecontexts The contexts these predictions belong to
* @param \core_analytics\prediction[] $predictions The prediction records
* @return null
*/
public function generate($samplecontexts, $predictions) {
$analyserclass = $this->target->get_analyser_class();
// We will need to restore it later.
$actuallanguage = current_language();
if ($analyserclass::one_sample_per_analysable()) {
// Iterate through the predictions and the users in each prediction (likely to be just one).
foreach ($predictions as $prediction) {
$context = $samplecontexts[$prediction->get_prediction_data()->contextid];
$users = $this->target->get_insights_users($context);
foreach ($users as $user) {
$this->set_notification_language($user);
list($insighturl, $fullmessage, $fullmessagehtml) = $this->prediction_info($prediction, $context, $user);
$this->notification($context, $user, $insighturl, $fullmessage, $fullmessagehtml);
}
}
} else {
// 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) {
$this->set_notification_language($user);
$insighturl = $this->target->get_insight_context_url($this->modelid, $context);
list($fullmessage, $fullmessagehtml) = $this->target->get_insight_body($context, $contextname, $user,
$insighturl);
$this->notification($context, $user, $insighturl, $fullmessage, $fullmessagehtml);
}
}
}
force_current_language($actuallanguage);
}
/**
* Generates a insight notification for the user.
*
* @param \context $context
* @param \stdClass $user
* @param \moodle_url $insighturl The insight URL
* @param string $fullmessage
* @param string $fullmessagehtml
* @return null
*/
private function notification(\context $context, \stdClass $user, \moodle_url $insighturl, string $fullmessage, string $fullmessagehtml) {
$message = new \core\message\message();
$message->component = 'moodle';
$message->name = 'insights';
$message->userfrom = \core_user::get_support_user();
$message->userto = $user;
$message->subject = $this->target->get_insight_subject($this->modelid, $context);
// Same than the subject.
$message->contexturlname = $message->subject;
$message->courseid = $this->get_context_courseid($context);
$message->fullmessage = $fullmessage;
$message->fullmessageformat = FORMAT_PLAIN;
$message->fullmessagehtml = $fullmessagehtml;
$message->smallmessage = $fullmessage;
$message->contexturl = $insighturl->out(false);
message_send($message);
}
/**
* Returns the course context of the provided context reading an internal cache first.
*
* @param \context $context
* @return int
*/
private function get_context_courseid(\context $context) {
if (empty($this->contextcourseids[$context->id])) {
$coursecontext = $context->get_course_context(false);
if (!$coursecontext) {
// Default to the frontpage course context.
$coursecontext = \context_course::instance(SITEID);
}
$this->contextcourseids[$context->id] = $coursecontext->instanceid;
}
return $this->contextcourseids[$context->id];
}
/**
* Extracts info from the prediction for display purposes.
*
* @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, \context $context, \stdClass $user) {
global $OUTPUT;
// The prediction actions get passed to the target so that it can show them in its preferred way.
$actions = array_merge(
$this->target->prediction_actions($prediction, true, true),
$this->target->bulk_actions([$prediction])
);
$predictioninfo = $this->target->get_insight_body_for_prediction($context, $user, $prediction, $actions);
// For FORMAT_PLAIN.
$fullmessageplaintext = '';
if (!empty($predictioninfo[FORMAT_PLAIN])) {
$fullmessageplaintext .= $predictioninfo[FORMAT_PLAIN];
}
$insighturl = $predictioninfo['url'] ?? null;
// For FORMAT_HTML.
$messageactions = [];
foreach ($actions as $action) {
if (!$action->get_url()->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.
$action->get_url()->param('forwardurl', $actiondoneurl->out(false));
}
if ($action->get_url()->param('predictionid') === null) {
// Bulk actions do not include the prediction id by default.
$action->get_url()->param('predictionid', $prediction->get_prediction_data()->id);
}
if (empty($insighturl)) {
// Ideally the target provides us with the best URL for the insight, if it doesn't we default
// to the first actions.
$insighturl = $action->get_url();
}
$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;
// We now process the HTML version actions, with a special treatment for useful/notuseful.
if ($action->get_action_name() === 'useful') {
$usefulurl = $actiondata->url;
} else if ($action->get_action_name() === 'notuseful') {
$notusefulurl = $actiondata->url;
} else {
$messageactions[] = $actiondata;
}
}
// 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' => !empty($usefulbuttons) ? $usefulbuttons : false,
'actions' => $messageactions,
'body' => $predictioninfo[FORMAT_HTML] ?? ''
];
$fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message_prediction', $contextinfo);
return [$insighturl, $fullmessageplaintext, $fullmessagehtml];
}
/**
* Sets the session language to the language used by the notification receiver.
*
* @param \stdClass $user The user who will receive the message
* @return null
*/
private function set_notification_language($user) {
global $CFG;
// Copied from current_language().
if (!empty($user->lang)) {
$lang = $user->lang;
} else if (isset($CFG->lang)) {
$lang = $CFG->lang;
} else {
$lang = 'en';
}
force_current_language($lang);
}
}
+525
View File
@@ -0,0 +1,525 @@
<?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/>.
/**
* Analysers base class.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\analyser;
defined('MOODLE_INTERNAL') || die();
/**
* Analysers base class.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class base {
/**
* @var int
*/
protected $modelid;
/**
* The model target.
*
* @var \core_analytics\local\target\base
*/
protected $target;
/**
* The model indicators.
*
* @var \core_analytics\local\indicator\base[]
*/
protected $indicators;
/**
* Time splitting methods to use.
*
* Multiple time splitting methods during evaluation and 1 single
* time splitting method once the model is enabled.
*
* @var \core_analytics\local\time_splitting\base[]
*/
protected $timesplittings;
/**
* Execution options.
*
* @var array
*/
protected $options;
/**
* Simple log array.
*
* @var string[]
*/
protected $log;
/**
* Constructor method.
*
* @param int $modelid
* @param \core_analytics\local\target\base $target
* @param \core_analytics\local\indicator\base[] $indicators
* @param \core_analytics\local\time_splitting\base[] $timesplittings
* @param array $options
* @return void
*/
public function __construct($modelid, \core_analytics\local\target\base $target, $indicators, $timesplittings, $options) {
$this->modelid = $modelid;
$this->target = $target;
$this->indicators = $indicators;
$this->timesplittings = $timesplittings;
if (empty($options['evaluation'])) {
$options['evaluation'] = false;
}
$this->options = $options;
// Checks if the analyser satisfies the indicators requirements.
$this->check_indicators_requirements();
$this->log = array();
}
/**
* @deprecated since Moodle 3.7
*/
public function get_analysables() {
throw new \coding_exception('get_analysables() method has been removed and cannot be used any more.');
}
/**
* Returns the list of analysable elements available on the site.
*
* A relatively complex SQL query should be set so that we take into account which analysable elements
* have already been processed and the order in which they have been processed. Helper methods are available
* to ease to implementation of get_analysables_iterator: get_iterator_sql and order_sql.
*
* @param string|null $action 'prediction', 'training' or null if no specific action needed.
* @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty.
* @return \Iterator
*/
abstract public function get_analysables_iterator(?string $action = null, array $contexts = []);
/**
* This function returns this analysable list of samples.
*
* @param \core_analytics\analysable $analysable
* @return array array[0] = int[] (sampleids) and array[1] = array (samplesdata)
*/
abstract public function get_all_samples(\core_analytics\analysable $analysable);
/**
* This function returns the samples data from a list of sample ids.
*
* @param int[] $sampleids
* @return array array[0] = int[] (sampleids) and array[1] = array (samplesdata)
*/
abstract public function get_samples($sampleids);
/**
* Returns the analysable of a sample.
*
* @param int $sampleid
* @return \core_analytics\analysable
*/
abstract public function get_sample_analysable($sampleid);
/**
* Returns the sample's origin in moodle database.
*
* @return string
*/
abstract public function get_samples_origin();
/**
* Returns the context of a sample.
*
* moodle/analytics:listinsights will be required at this level to access the sample predictions.
*
* @param int $sampleid
* @return \context
*/
abstract public function sample_access_context($sampleid);
/**
* Describes a sample with a description summary and a \renderable (an image for example)
*
* @param int $sampleid
* @param int $contextid
* @param array $sampledata
* @return array array(string, \renderable)
*/
abstract public function sample_description($sampleid, $contextid, $sampledata);
/**
* Model id getter.
* @return int
*/
public function get_modelid(): int {
return $this->modelid;
}
/**
* Options getter.
* @return array
*/
public function get_options(): array {
return $this->options;
}
/**
* Returns the analysed target.
*
* @return \core_analytics\local\target\base
*/
public function get_target(): \core_analytics\local\target\base {
return $this->target;
}
/**
* Getter for time splittings.
*
* @return \core_analytics\local\time_splitting\base
*/
public function get_timesplittings(): array {
return $this->timesplittings;
}
/**
* Getter for indicators.
*
* @return \core_analytics\local\indicator\base
*/
public function get_indicators(): array {
return $this->indicators;
}
/**
* Instantiate the indicators.
*
* @return \core_analytics\local\indicator\base[]
*/
public function instantiate_indicators() {
foreach ($this->indicators as $key => $indicator) {
$this->indicators[$key] = call_user_func(array($indicator, 'instance'));
}
// Free memory ASAP.
gc_collect_cycles();
gc_mem_caches();
return $this->indicators;
}
/**
* Samples data this analyser provides.
*
* @return string[]
*/
protected function provided_sample_data() {
return array($this->get_samples_origin());
}
/**
* Returns labelled data (training and evaluation).
*
* @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
* @return \stored_file[]
*/
public function get_labelled_data(array $contexts = []) {
// Delegates all processing to the analysis.
$result = new \core_analytics\local\analysis\result_file($this->get_modelid(), true, $this->get_options());
$analysis = new \core_analytics\analysis($this, true, $result);
$analysis->run($contexts);
return $result->get();
}
/**
* Returns unlabelled data (prediction).
*
* @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
* @return \stored_file[]
*/
public function get_unlabelled_data(array $contexts = []) {
// Delegates all processing to the analysis.
$result = new \core_analytics\local\analysis\result_file($this->get_modelid(), false, $this->get_options());
$analysis = new \core_analytics\analysis($this, false, $result);
$analysis->run($contexts);
return $result->get();
}
/**
* Returns indicator calculations as an array.
*
* @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
* @return array
*/
public function get_static_data(array $contexts = []) {
// Delegates all processing to the analysis.
$result = new \core_analytics\local\analysis\result_array($this->get_modelid(), false, $this->get_options());
$analysis = new \core_analytics\analysis($this, false, $result);
$analysis->run($contexts);
return $result->get();
}
/**
* Checks if the analyser satisfies all the model indicators requirements.
*
* @throws \core_analytics\requirements_exception
* @return void
*/
protected function check_indicators_requirements() {
foreach ($this->indicators as $indicator) {
$missingrequired = $this->check_indicator_requirements($indicator);
if ($missingrequired !== true) {
throw new \core_analytics\requirements_exception(get_class($indicator) . ' indicator requires ' .
json_encode($missingrequired) . ' sample data which is not provided by ' . get_class($this));
}
}
}
/**
* Checks that this analyser satisfies the provided indicator requirements.
*
* @param \core_analytics\local\indicator\base $indicator
* @return true|string[] True if all good, missing requirements list otherwise
*/
public function check_indicator_requirements(\core_analytics\local\indicator\base $indicator) {
$providedsampledata = $this->provided_sample_data();
$requiredsampledata = $indicator::required_sample_data();
if (empty($requiredsampledata)) {
// The indicator does not need any sample data.
return true;
}
$missingrequired = array_diff($requiredsampledata, $providedsampledata);
if (empty($missingrequired)) {
return true;
}
return $missingrequired;
}
/**
* Adds a register to the analysis log.
*
* @param string $string
* @return void
*/
public function add_log($string) {
$this->log[] = $string;
}
/**
* Returns the analysis logs.
*
* @return string[]
*/
public function get_logs() {
return $this->log;
}
/**
* Whether the plugin needs user data clearing or not.
*
* This is related to privacy. Override this method if your analyser samples have any relation
* to the 'user' database entity. We need to clean the site from all user-related data if a user
* request their data to be deleted from the system. A static::provided_sample_data returning 'user'
* is an indicator that you should be returning true.
*
* @return bool
*/
public function processes_user_data() {
return false;
}
/**
* SQL JOIN from a sample to users table.
*
* This function should be defined if static::processes_user_data returns true and it is related to analytics API
* privacy API implementation. It allows the analytics API to identify data associated to users that needs to be
* deleted or exported.
*
* This function receives the alias of a table with a 'sampleid' field and it should return a SQL join
* with static::get_samples_origin and with 'user' table. Note that:
* - The function caller expects the returned 'user' table to be aliased as 'u' (defacto standard in moodle).
* - You can join with other tables if your samples origin table does not contain a 'userid' field (if that would be
* a requirement this solution would be automated for you) you can't though use the following
* aliases: 'ap', 'apa', 'aic' and 'am'.
*
* Some examples:
*
* static::get_samples_origin() === 'user':
* JOIN {user} u ON {$sampletablealias}.sampleid = u.id
*
* static::get_samples_origin() === 'role_assignments':
* JOIN {role_assignments} ra ON {$sampletablealias}.sampleid = ra.userid JOIN {user} u ON u.id = ra.userid
*
* static::get_samples_origin() === 'user_enrolments':
* JOIN {user_enrolments} ue ON {$sampletablealias}.sampleid = ue.userid JOIN {user} u ON u.id = ue.userid
*
* @throws \coding_exception
* @param string $sampletablealias The alias of the table with a sampleid field that will join with this SQL string
* @return string
*/
public function join_sample_user($sampletablealias) {
throw new \coding_exception('This method should be implemented if static::processes_user_data returns true.');
}
/**
* Do this analyser's analysables have 1 single sample each?
*
* Overwrite and return true if your analysables only have
* one sample. The insights generated by models using this
* analyser will then include the suggested actions in the
* notification.
*
* @return bool
*/
public static function one_sample_per_analysable() {
return false;
}
/**
* Returns an array of context levels that can be used to restrict the contexts used during analysis.
*
* The contexts provided to self::get_analysables_iterator will match these contextlevels.
*
* @return array Array of context levels or an empty array if context restriction is not supported.
*/
public static function context_restriction_support(): array {
return [];
}
/**
* Returns the possible contexts used by the analyser.
*
* This method uses separate logic for each context level because to iterate through
* the list of contexts calling get_context_name for each of them would be expensive
* in performance terms.
*
* This generic implementation returns all the contexts in the site for the provided context level.
* Overwrite it for specific restrictions in your analyser.
*
* @param string|null $query Context name filter.
* @return int[]
*/
public static function potential_context_restrictions(string $query = null) {
return \core_analytics\manager::get_potential_context_restrictions(static::context_restriction_support(), $query);
}
/**
* Get the sql of a default implementation of the iterator.
*
* This method only works for analysers that return analysable elements which ids map to a context instance ids.
*
* @param string $tablename The name of the table
* @param int $contextlevel The context level of the analysable
* @param string|null $action
* @param string|null $tablealias The table alias
* @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables if empty.
* @return array [0] => sql and [1] => params array
*/
protected function get_iterator_sql(string $tablename, int $contextlevel, ?string $action = null, ?string $tablealias = null,
array $contexts = []) {
global $DB;
if (!$tablealias) {
$tablealias = 'analysable';
}
$params = ['contextlevel' => $contextlevel, 'modelid' => $this->get_modelid()];
$select = $tablealias . '.*, ' . \context_helper::get_preload_record_columns_sql('ctx');
// We add the action filter on ON instead of on WHERE because otherwise records are not returned if there are existing
// records for another action or model.
$usedanalysablesjoin = ' LEFT JOIN {analytics_used_analysables} aua ON ' . $tablealias . '.id = aua.analysableid AND ' .
'(aua.modelid = :modelid OR aua.modelid IS NULL)';
if ($action) {
$usedanalysablesjoin .= " AND aua.action = :action";
$params = $params + ['action' => $action];
}
$sql = 'SELECT ' . $select . '
FROM {' . $tablename . '} ' . $tablealias . '
' . $usedanalysablesjoin . '
JOIN {context} ctx ON (ctx.contextlevel = :contextlevel AND ctx.instanceid = ' . $tablealias . '.id) ';
if (!$contexts) {
// Adding the 1 = 1 just to have the WHERE part so that all further conditions
// added by callers can be appended to $sql with and ' AND'.
$sql .= 'WHERE 1 = 1';
} else {
$contextsqls = [];
foreach ($contexts as $context) {
$paramkey1 = 'paramctxlike' . $context->id;
$paramkey2 = 'paramctxeq' . $context->id;
$contextsqls[] = $DB->sql_like('ctx.path', ':' . $paramkey1);
$contextsqls[] = 'ctx.path = :' . $paramkey2;
// This includes the context itself.
$params[$paramkey1] = $context->path . '/%';
$params[$paramkey2] = $context->path;
}
$sql .= 'WHERE (' . implode(' OR ', $contextsqls) . ')';
}
return [$sql, $params];
}
/**
* Returns the order by clause.
*
* @param string|null $fieldname The field name
* @param string $order 'ASC' or 'DESC'
* @param string|null $tablealias The table alias of the field
* @return string
*/
protected function order_sql(?string $fieldname = null, string $order = 'ASC', ?string $tablealias = null) {
if (!$tablealias) {
$tablealias = 'analysable';
}
if ($order != 'ASC' && $order != 'DESC') {
throw new \coding_exception('The order can only be ASC or DESC');
}
$ordersql = ' ORDER BY (CASE WHEN aua.timeanalysed IS NULL THEN 0 ELSE aua.timeanalysed END) ASC';
if ($fieldname) {
$ordersql .= ', ' . $tablealias . '.' . $fieldname .' ' . $order;
}
return $ordersql;
}
}
@@ -0,0 +1,77 @@
<?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/>.
/**
* Abstract analyser in course basis.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\analyser;
defined('MOODLE_INTERNAL') || die();
/**
* Abstract analyser in course basis.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class by_course extends base {
/**
* Return the list of courses to analyse.
*
* @param string|null $action 'prediction', 'training' or null if no specific action needed.
* @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty.
* @return \Iterator
*/
public function get_analysables_iterator(?string $action = null, array $contexts = []) {
global $DB;
list($sql, $params) = $this->get_iterator_sql('course', CONTEXT_COURSE, $action, 'c', $contexts);
$ordersql = $this->order_sql('sortorder', 'ASC', 'c');
$recordset = $DB->get_recordset_sql($sql . $ordersql, $params);
if (!$recordset->valid()) {
$this->add_log(get_string('nocourses', 'analytics'));
return new \ArrayIterator([]);
}
return new \core\dml\recordset_walk($recordset, function($record) {
if ($record->id == SITEID) {
return false;
}
$context = \context_helper::preload_from_record($record);
return \core_analytics\course::instance($record, $context);
});
}
/**
* Can be limited to course categories or specific courses.
*
* @return array
*/
public static function context_restriction_support(): array {
return [CONTEXT_COURSE, CONTEXT_COURSECAT];
}
}
@@ -0,0 +1,49 @@
<?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/>.
/**
* Site-level contents abstract analysable.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\analyser;
defined('MOODLE_INTERNAL') || die();
/**
* Site-level contents abstract analysable.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class sitewide extends base {
/**
* Return the list of analysables to analyse.
*
* @param string|null $action 'prediction', 'training' or null if no specific action needed.
* @param \context[] $contexts Ignored here.
* @return \Iterator
*/
public function get_analysables_iterator(?string $action = null, array $contexts = []) {
// We can safely ignore $action as we have 1 single analysable element in this analyser.
return new \ArrayIterator([new \core_analytics\site()]);
}
}
+102
View File
@@ -0,0 +1,102 @@
<?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/>.
/**
* Keeps track of the analysis results.
*
* @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\local\analysis;
defined('MOODLE_INTERNAL') || die();
/**
* Keeps track of the analysis results.
*
* @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
*/
abstract class result {
/**
* @var int
*/
protected $modelid;
/**
* @var bool
*/
protected $includetarget;
/**
* @var array Analysis options
*/
protected $options;
/**
* Stores analysis data at instance level.
* @param int $modelid
* @param bool $includetarget
* @param array $options
*/
public function __construct(int $modelid, bool $includetarget, array $options) {
$this->modelid = $modelid;
$this->includetarget = $includetarget;
$this->options = $options;
}
/**
* Retrieves cached results during evaluation.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \core_analytics\analysable $analysable
* @return mixed It can be in whatever format the result uses.
*/
public function retrieve_cached_result(\core_analytics\local\time_splitting\base $timesplitting,
\core_analytics\analysable $analysable) {
return false;
}
/**
* Stores the analysis results.
*
* @param array $results
* @return bool True if anything was successfully analysed
*/
abstract public function add_analysable_results(array $results): bool;
/**
* Formats the result.
*
* @param array $data
* @param \core_analytics\local\target\base $target
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \core_analytics\analysable $analysable
* @return mixed It can be in whatever format the result uses
*/
abstract public function format_result(array $data, \core_analytics\local\target\base $target,
\core_analytics\local\time_splitting\base $timesplitting, \core_analytics\analysable $analysable);
/**
* Returns the results of the analysis.
* @return array
*/
abstract public function get(): array;
}
@@ -0,0 +1,100 @@
<?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/>.
/**
* Keeps track of the analysis results by storing the results in an array.
*
* @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\local\analysis;
defined('MOODLE_INTERNAL') || die();
/**
* Keeps track of the analysis results by storing the results in an array.
*
* @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 result_array extends result {
/**
* Stores the analysis results by time-splitting method.
* @var array
*/
private $resultsbytimesplitting = [];
/**
* Stores the analysis results.
* @param array $results
* @return bool True if anything was successfully analysed
*/
public function add_analysable_results(array $results): bool {
$any = false;
// Process all provided time splitting methods.
foreach ($results as $timesplittingid => $result) {
if (!empty($result->result)) {
if (empty($this->resultsbytimesplitting[$timesplittingid])) {
$this->resultsbytimesplitting[$timesplittingid] = [];
}
$this->resultsbytimesplitting[$timesplittingid] += $result->result;
$any = true;
}
}
if (empty($any)) {
return false;
}
return true;
}
/**
* Formats the result.
*
* @param array $data
* @param \core_analytics\local\target\base $target
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \core_analytics\analysable $analysable
* @return mixed The data as it comes
*/
public function format_result(array $data, \core_analytics\local\target\base $target,
\core_analytics\local\time_splitting\base $timesplitting, \core_analytics\analysable $analysable) {
return $data;
}
/**
* Returns the results of the analysis.
* @return array
*/
public function get(): array {
// We join the datasets by time splitting method.
$timesplittingresults = array();
foreach ($this->resultsbytimesplitting as $timesplittingid => $results) {
if (empty($timesplittingresults[$timesplittingid])) {
$timesplittingresults[$timesplittingid] = [];
}
$timesplittingresults[$timesplittingid] += $results;
}
return $timesplittingresults;
}
}
@@ -0,0 +1,228 @@
<?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/>.
/**
* Keeps track of the analysis results by storing the results in files.
*
* @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\local\analysis;
defined('MOODLE_INTERNAL') || die();
/**
* Keeps track of the analysis results by storing the results in files.
*
* @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 result_file extends result {
/**
* Stores the analysis results by time-splitting method.
* @var array
*/
private $filesbytimesplitting = [];
/**
* Stores the analysis results.
* @param array $results
* @return bool True if anything was successfully analysed
*/
public function add_analysable_results(array $results): bool {
$any = false;
// Process all provided time splitting methods.
foreach ($results as $timesplittingid => $result) {
if (!empty($result->result)) {
$this->filesbytimesplitting[$timesplittingid][] = $result->result;
$any = true;
}
}
if (empty($any)) {
return false;
}
return true;
}
/**
* Retrieves cached results during evaluation.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \core_analytics\analysable $analysable
* @return mixed A \stored_file in this case.
*/
public function retrieve_cached_result(\core_analytics\local\time_splitting\base $timesplitting,
\core_analytics\analysable $analysable) {
// For evaluation purposes we don't need to be that strict about how updated the data is,
// if this analyser was analysed less that 1 week ago we skip generating a new one. This
// helps scale the evaluation process as sites with tons of courses may need a lot of time to
// complete an evaluation.
if (!empty($this->options['evaluation']) && !empty($this->options['reuseprevanalysed'])) {
$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.
return $previousanalysis;
}
}
return false;
}
/**
* Formats the result.
*
* @param array $data
* @param \core_analytics\local\target\base $target
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \core_analytics\analysable $analysable
* @return mixed A \stored_file in this case
*/
public function format_result(array $data, \core_analytics\local\target\base $target,
\core_analytics\local\time_splitting\base $timesplitting, \core_analytics\analysable $analysable) {
if (!empty($this->includetarget)) {
$filearea = \core_analytics\dataset_manager::LABELLED_FILEAREA;
} else {
$filearea = \core_analytics\dataset_manager::UNLABELLED_FILEAREA;
}
$dataset = new \core_analytics\dataset_manager($this->modelid, $analysable->get_id(),
$timesplitting->get_id(), $filearea, $this->options['evaluation']);
// Add extra metadata.
$this->add_model_metadata($data, $timesplitting, $target);
// Write all calculated data to a file.
if (!$result = $dataset->store($data)) {
return false;
}
return $result;
}
/**
* Returns the results of the analysis.
* @return array
*/
public function get(): array {
if ($this->options['evaluation'] === false) {
// Look for previous training and prediction files we generated and couldn't be used
// by machine learning backends because they weren't big enough.
$pendingfiles = \core_analytics\dataset_manager::get_pending_files($this->modelid, $this->includetarget,
array_keys($this->filesbytimesplitting));
foreach ($pendingfiles as $timesplittingid => $files) {
foreach ($files as $file) {
$this->filesbytimesplitting[$timesplittingid][] = $file;
}
}
}
// We join the datasets by time splitting method.
$timesplittingfiles = array();
foreach ($this->filesbytimesplitting as $timesplittingid => $files) {
if ($this->options['evaluation'] === true) {
// Delete the previous copy. Only when evaluating.
\core_analytics\dataset_manager::delete_previous_evaluation_file($this->modelid, $timesplittingid);
}
// Merge all course files into one.
if ($this->includetarget) {
$filearea = \core_analytics\dataset_manager::LABELLED_FILEAREA;
} else {
$filearea = \core_analytics\dataset_manager::UNLABELLED_FILEAREA;
}
$timesplittingfiles[$timesplittingid] = \core_analytics\dataset_manager::merge_datasets($files,
$this->modelid, $timesplittingid, $filearea, $this->options['evaluation']);
}
if (!empty($pendingfiles)) {
// We must remove them now as they are already part of another dataset.
foreach ($pendingfiles as $timesplittingid => $files) {
foreach ($files as $file) {
$file->delete();
}
}
}
return $timesplittingfiles;
}
/**
* Adds target metadata to the dataset.
*
* The final dataset document will look like this:
* ----------------------------------------------------
* metadata1,metadata2,metadata3,.....
* value1, value2, value3,.....
*
* header1,header2,header3,header4,.....
* stud1value1,stud1value2,stud1value3,stud1value4,.....
* stud2value1,stud2value2,stud2value3,stud2value4,.....
* .....
* ----------------------------------------------------
*
* @param array $data
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \core_analytics\local\target\base $target
* @return null
*/
private function add_model_metadata(array &$data, \core_analytics\local\time_splitting\base $timesplitting,
\core_analytics\local\target\base $target) {
global $CFG;
// If no target the first column is the sampleid, if target the last column is the target.
// This will need to be updated when we support unsupervised learning models.
$metadata = array(
'timesplitting' => $timesplitting->get_id(),
'nfeatures' => count(current($data)) - 1,
'moodleversion' => $CFG->version,
'targetcolumn' => $target->get_id()
);
if ($target->is_linear()) {
$metadata['targettype'] = 'linear';
$metadata['targetmin'] = $target::get_min_value();
$metadata['targetmax'] = $target::get_max_value();
} else {
$metadata['targettype'] = 'discrete';
$metadata['targetclasses'] = json_encode($target::get_classes());
}
// The first 2 samples will be used to store metadata about the dataset.
$metadatacolumns = [];
$metadatavalues = [];
foreach ($metadata as $key => $value) {
$metadatacolumns[] = $key;
$metadatavalues[] = $value;
}
// This will also reset samples' dataset keys.
array_unshift($data, $metadatacolumns, $metadatavalues);
}
}
+197
View File
@@ -0,0 +1,197 @@
<?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/>.
/**
* Abstract base indicator.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\indicator;
defined('MOODLE_INTERNAL') || die();
/**
* Abstract base indicator.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class base extends \core_analytics\calculable {
/**
* Min value an indicator can return.
*/
const MIN_VALUE = -1;
/**
* Max value an indicator can return.
*/
const MAX_VALUE = 1;
/**
* Converts the calculated indicators to dataset feature/s.
*
* @param float|int[] $calculatedvalues
* @return array
*/
abstract protected function to_features($calculatedvalues);
/**
* Calculates the sample.
*
* Return a value from self::MIN_VALUE to self::MAX_VALUE or null if the indicator can not be calculated for this sample.
*
* @param int $sampleid
* @param string $sampleorigin
* @param integer $starttime Limit the calculation to this timestart
* @param integer $endtime Limit the calculation to this timeend
* @return float|null
*/
abstract protected function calculate_sample($sampleid, $sampleorigin, $starttime, $endtime);
/**
* Should this value be displayed?
*
* Indicators providing multiple features can be used this method to discard some of them.
*
* @param float $value
* @param string $subtype
* @return bool
*/
public function should_be_displayed($value, $subtype) {
// We should everything by default.
return true;
}
/**
* Allows indicators to specify data they need.
*
* e.g. A model using courses as samples will not provide users data, but an indicator like
* "user is hungry" needs user data.
*
* @return null|string[] Name of the required elements (use the database tablename)
*/
public static function required_sample_data() {
return null;
}
/**
* Returns an instance of the indicator.
*
* Useful to reset cached data.
*
* @return \core_analytics\local\indicator\base
*/
public static function instance() {
return new static();
}
/**
* Returns the maximum value an indicator calculation can return.
*
* @return float
*/
public static function get_max_value() {
return self::MAX_VALUE;
}
/**
* Returns the minimum value an indicator calculation can return.
*
* @return float
*/
public static function get_min_value() {
return self::MIN_VALUE;
}
/**
* Hook to allow indicators to pre-fill data that is shared accross time range calculations.
*
* Useful to fill analysable-dependant data that does not depend on the time ranges. Use
* instance vars to cache data that can be re-used across samples calculations but changes
* between time ranges (indicator instances are reset between time ranges to avoid unexpected
* problems).
*
* You are also responsible of emptying previous analysable caches.
*
* @param \core_analytics\analysable $analysable
* @return void
*/
public function fill_per_analysable_caches(\core_analytics\analysable $analysable) {
}
/**
* Calculates the indicator.
*
* Returns an array of values which size matches $sampleids size.
*
* @param int[] $sampleids
* @param string $samplesorigin
* @param integer $starttime Limit the calculation to this timestart
* @param integer $endtime Limit the calculation to this timeend
* @param array $existingcalculations Existing calculations of this indicator, indexed by sampleid.
* @return array [0] = [$sampleid => int[]|float[]], [1] = [$sampleid => int|float], [2] = [$sampleid => $sampleid]
*/
public function calculate($sampleids, $samplesorigin, $starttime = false, $endtime = false, $existingcalculations = array()) {
if (!PHPUNIT_TEST && CLI_SCRIPT) {
echo '.';
}
$calculations = array();
$newcalculations = array();
$notnulls = array();
foreach ($sampleids as $sampleid => $unusedsampleid) {
if (isset($existingcalculations[$sampleid])) {
$calculatedvalue = $existingcalculations[$sampleid];
} else {
$calculatedvalue = $this->calculate_sample($sampleid, $samplesorigin, $starttime, $endtime);
$newcalculations[$sampleid] = $calculatedvalue;
}
if (!is_null($calculatedvalue)) {
$notnulls[$sampleid] = $sampleid;
$this->validate_calculated_value($calculatedvalue);
}
$calculations[$sampleid] = $calculatedvalue;
}
$features = $this->to_features($calculations);
return array($features, $newcalculations, $notnulls);
}
/**
* Validates the calculated value.
*
* @throws \coding_exception
* @param float $calculatedvalue
* @return true
*/
protected function validate_calculated_value($calculatedvalue) {
if ($calculatedvalue > self::MAX_VALUE || $calculatedvalue < self::MIN_VALUE) {
throw new \coding_exception('Calculated values should be higher than ' . self::MIN_VALUE .
' and lower than ' . self::MAX_VALUE . ' ' . $calculatedvalue . ' received');
}
return true;
}
}
@@ -0,0 +1,128 @@
<?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/>.
/**
* Abstract binary indicator.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\indicator;
defined('MOODLE_INTERNAL') || die();
/**
* Abstract binary indicator.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class binary extends discrete {
/**
* get_classes
*
* @return array
*/
final public static function get_classes() {
return [-1, 1];
}
/**
* It should always be displayed.
*
* Binary values have no subtypes by default, please overwrite if
* your indicator is adding extra features.
*
* @param float $value
* @param string $subtype
* @return bool
*/
public function should_be_displayed($value, $subtype) {
if ($subtype != false) {
return false;
}
return true;
}
/**
* get_display_value
*
* @param float $value
* @param string $subtype
* @return string
*/
public function get_display_value($value, $subtype = false) {
// No subtypes for binary values by default.
if ($value == -1) {
return get_string('no');
} else if ($value == 1) {
return get_string('yes');
} else {
throw new \moodle_exception('errorpredictionformat', 'analytics');
}
}
/**
* get_calculation_outcome
*
* @param float $value
* @param string $subtype
* @return int
*/
public function get_calculation_outcome($value, $subtype = false) {
// No subtypes for binary values by default.
if ($value == -1) {
return self::OUTCOME_NEGATIVE;
} else if ($value == 1) {
return self::OUTCOME_OK;
} else {
throw new \moodle_exception('errorpredictionformat', 'analytics');
}
}
/**
* get_feature_headers
*
* @return array
*/
public static function get_feature_headers() {
// Just 1 single feature obtained from the calculated value.
return array('\\' . get_called_class());
}
/**
* to_features
*
* @param array $calculatedvalues
* @return array
*/
protected function to_features($calculatedvalues) {
// Indicators with binary values have only 1 feature for indicator, here we do nothing else
// than converting each sample scalar value to an array of scalars with 1 element.
array_walk($calculatedvalues, function(&$calculatedvalue) {
// Just return it as an array.
$calculatedvalue = array($calculatedvalue);
});
return $calculatedvalues;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
<?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/>.
/**
* Abstract discrete indicator.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\indicator;
defined('MOODLE_INTERNAL') || die();
/**
* Abstract discrete indicator.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class discrete extends base {
/**
* Classes need to be defined so they can be converted internally to individual dataset features.
*
* @return string[]
*/
protected static function get_classes() {
throw new \coding_exception('Please overwrite get_classes() specifying your discrete-values\' indicator classes');
}
/**
* Returns 1 feature header for each of the classes.
*
* @return string[]
*/
public static function get_feature_headers() {
$fullclassname = '\\' . get_called_class();
foreach (static::get_classes() as $class) {
$headers[] = $fullclassname . '/' . $class;
}
return $headers;
}
/**
* Whether the value should be displayed or not.
*
* @param float $value
* @param string $subtype
* @return bool
*/
public function should_be_displayed($value, $subtype) {
if ($value != static::get_max_value()) {
// Discrete values indicators are converted internally to 1 feature per indicator, we are only interested
// in showing the feature flagged with the max value.
return false;
}
return true;
}
/**
* Returns the value to display when the prediction is $value.
*
* @param float $value
* @param string $subtype
* @return string
*/
public function get_display_value($value, $subtype = false) {
$displayvalue = array_search($subtype, static::get_classes(), false);
debugging('Please overwrite \core_analytics\local\indicator\discrete::get_display_value to show something ' .
'different than the default "' . $displayvalue . '"', DEBUG_DEVELOPER);
return $displayvalue;
}
/**
* get_display_style
*
* @param float $ignoredvalue
* @param string $ignoredsubtype
* @return int
*/
public function get_display_style($ignoredvalue, $ignoredsubtype) {
// No style attached to indicators classes, they are what they are, a cat,
// a horse or a sandwich, they are not good or bad.
return \core_analytics\calculable::OUTCOME_NEUTRAL;
}
/**
* From calculated values to dataset features.
*
* One column for each class.
*
* @param float[] $calculatedvalues
* @return float[]
*/
protected function to_features($calculatedvalues) {
$classes = static::get_classes();
foreach ($calculatedvalues as $sampleid => $calculatedvalue) {
// Using intval as it may come as a float from the db.
$classindex = array_search(intval($calculatedvalue), $classes, true);
if ($classindex === false && !is_null($calculatedvalue)) {
throw new \coding_exception(get_class($this) . ' calculated value "' . $calculatedvalue .
'" is not one of its defined classes (' . json_encode($classes) . ')');
}
// We transform the calculated value into multiple features, one for each of the possible classes.
$features = array_fill(0, count($classes), 0);
// 1 to the selected value.
if (!is_null($calculatedvalue)) {
$features[$classindex] = 1;
}
$calculatedvalues[$sampleid] = $features;
}
return $calculatedvalues;
}
/**
* Validates the calculated value.
*
* @param float $calculatedvalue
* @return true
*/
protected function validate_calculated_value($calculatedvalue) {
// Using intval as it may come as a float from the db.
if (!in_array(intval($calculatedvalue), static::get_classes())) {
throw new \coding_exception(get_class($this) . ' calculated value "' . $calculatedvalue .
'" is not one of its defined classes (' . json_encode(static::get_classes()) . ')');
}
return true;
}
}
@@ -0,0 +1,147 @@
<?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/>.
/**
* Abstract linear indicator.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\indicator;
defined('MOODLE_INTERNAL') || die();
/**
* Abstract linear indicator.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class linear extends base {
/**
* Set to false to avoid context features to be added as dataset features.
*
* @return bool
*/
protected static function include_averages() {
return true;
}
/**
* get_feature_headers
*
* @return array
*/
public static function get_feature_headers() {
$fullclassname = '\\' . get_called_class();
if (static::include_averages()) {
// The calculated value + context indicators.
$headers = array($fullclassname, $fullclassname . '/mean');
} else {
$headers = array($fullclassname);
}
return $headers;
}
/**
* Show only the main feature.
*
* @param float $value
* @param string $subtype
* @return bool
*/
public function should_be_displayed($value, $subtype) {
if ($subtype != false) {
return false;
}
return true;
}
/**
* get_display_value
*
* @param float $value
* @param string $subtype
* @return string
*/
public function get_display_value($value, $subtype = false) {
$diff = static::get_max_value() - static::get_min_value();
return round(100 * ($value - static::get_min_value()) / $diff) . '%';
}
/**
* get_calculation_outcome
*
* @param float $value
* @param string $subtype
* @return int
*/
public function get_calculation_outcome($value, $subtype = false) {
if ($value < 0) {
return self::OUTCOME_NEGATIVE;
} else {
return self::OUTCOME_OK;
}
}
/**
* Converts the calculated values to a list of features for the dataset.
*
* @param array $calculatedvalues
* @return array
*/
protected function to_features($calculatedvalues) {
// Null mean if all calculated values are null.
$nullmean = true;
foreach ($calculatedvalues as $value) {
if (!is_null($value)) {
// Early break, we don't want to spend a lot of time here.
$nullmean = false;
break;
}
}
if ($nullmean) {
$mean = null;
} else {
$mean = round(array_sum($calculatedvalues) / count($calculatedvalues), 2);
}
foreach ($calculatedvalues as $sampleid => $calculatedvalue) {
if (!is_null($calculatedvalue)) {
$calculatedvalue = round($calculatedvalue, 2);
}
if (static::include_averages()) {
$calculatedvalues[$sampleid] = array($calculatedvalue, $mean);
} else {
// Basically just convert the scalar to an array of scalars with a single value.
$calculatedvalues[$sampleid] = array($calculatedvalue);
}
}
// Returns each sample as an array of values, appending the mean to the calculated value.
return $calculatedvalues;
}
}
+459
View File
@@ -0,0 +1,459 @@
<?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/>.
/**
* Abstract base target.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\target;
defined('MOODLE_INTERNAL') || die();
/**
* Abstract base target.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class base extends \core_analytics\calculable {
/**
* This target have linear or discrete values.
*
* @return bool
*/
abstract public function is_linear();
/**
* Returns the analyser class that should be used along with this target.
*
* @return string The full class name as a string
*/
abstract public function get_analyser_class();
/**
* Allows the target to verify that the analysable is a good candidate.
*
* This method can be used as a quick way to discard invalid analysables.
* e.g. Imagine that your analysable don't have students and you need them.
*
* @param \core_analytics\analysable $analysable
* @param bool $fortraining
* @return true|string
*/
abstract public function is_valid_analysable(\core_analytics\analysable $analysable, $fortraining = true);
/**
* Is this sample from the $analysable valid?
*
* @param int $sampleid
* @param \core_analytics\analysable $analysable
* @param bool $fortraining
* @return bool
*/
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 $sampleid
* @param \core_analytics\analysable $analysable
* @param int|false $starttime Limit calculations to start time
* @param int|false $endtime Limit calculations to end time
* @return float|null
*/
abstract protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false);
/**
* Can the provided time-splitting method be used on this target?.
*
* Time-splitting methods not matching the target requirements will not be selectable by models based on this target.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @return bool
*/
abstract public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool;
/**
* Is this target generating insights?
*
* Defaults to true.
*
* @return bool
*/
public static function uses_insights() {
return true;
}
/**
* Should the insights of this model be linked from reports?
*
* @return bool
*/
public function link_insights_report(): bool {
return true;
}
/**
* Based on facts (processed by machine learning backends) by default.
*
* @return bool
*/
public static function based_on_assumptions() {
return false;
}
/**
* Update the last analysis time on analysable processed or always.
*
* If you overwrite this method to return false the last analysis time
* will only be recorded in DB when the element successfully analysed. You can
* safely return false for lightweight targets.
*
* @return bool
*/
public function always_update_analysis_time(): bool {
return true;
}
/**
* Suggested actions for a user.
*
* @param \core_analytics\prediction $prediction
* @param bool $includedetailsaction
* @param bool $isinsightuser Force all the available actions to be returned as it the user who
* receives the insight is the one logged in.
* @return \core_analytics\prediction_action[]
*/
public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false,
$isinsightuser = false) {
global $PAGE;
$predictionid = $prediction->get_prediction_data()->id;
$contextid = $prediction->get_prediction_data()->contextid;
$modelid = $prediction->get_prediction_data()->modelid;
$actions = array();
if ($this->link_insights_report() && $includedetailsaction) {
$predictionurl = new \moodle_url('/report/insights/prediction.php', array('id' => $predictionid));
$detailstext = $this->get_view_details_text();
$actions[] = new \core_analytics\prediction_action(\core_analytics\prediction::ACTION_PREDICTION_DETAILS, $prediction,
$predictionurl, new \pix_icon('t/preview', $detailstext),
$detailstext, false, [], \core_analytics\action::TYPE_NEUTRAL);
}
return $actions;
}
/**
* Suggested bulk actions for a user.
*
* @param \core_analytics\prediction[] $predictions List of predictions suitable for the bulk actions to use.
* @return \core_analytics\bulk_action[] The list of bulk actions.
*/
public function bulk_actions(array $predictions) {
$analyserclass = $this->get_analyser_class();
if ($analyserclass::one_sample_per_analysable()) {
// Default actions are useful / not useful.
$actions = [
\core_analytics\default_bulk_actions::useful(),
\core_analytics\default_bulk_actions::not_useful()
];
} else {
// Accept and not applicable.
$actions = [
\core_analytics\default_bulk_actions::accept(),
\core_analytics\default_bulk_actions::not_applicable()
];
if (!self::based_on_assumptions()) {
// We include incorrectly flagged.
$actions[] = \core_analytics\default_bulk_actions::incorrectly_flagged();
}
}
return $actions;
}
/**
* Adds the JS required to run the bulk actions.
*/
public function add_bulk_actions_js() {
global $PAGE;
$PAGE->requires->js_call_amd('report_insights/actions', 'initBulk', ['.insights-bulk-actions']);
}
/**
* Returns the view details link text.
* @return string
*/
private function get_view_details_text() {
if ($this->based_on_assumptions()) {
$analyserclass = $this->get_analyser_class();
if ($analyserclass::one_sample_per_analysable()) {
$detailstext = get_string('viewinsightdetails', 'analytics');
} else {
$detailstext = get_string('viewdetails', 'analytics');
}
} else {
$detailstext = get_string('viewprediction', 'analytics');
}
return $detailstext;
}
/**
* Callback to execute once a prediction has been returned from the predictions processor.
*
* Note that the analytics_predictions db record is not yet inserted.
*
* @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, $rangeindex, \context $samplecontext, $prediction, $predictionscore) {
return;
}
/**
* Generates insights notifications
*
* @param int $modelid
* @param \context[] $samplecontexts
* @param \core_analytics\prediction[] $predictions
* @return void
*/
public function generate_insight_notifications($modelid, $samplecontexts, array $predictions = []) {
// Delegate the processing of insights to the insights_generator.
$insightsgenerator = new \core_analytics\insights_generator($modelid, $this);
$insightsgenerator->generate($samplecontexts, $predictions);
}
/**
* 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
* or moodle/analytics:listowninsights capability is required to access the list of insights.
*
* @param \context $context
* @return array
*/
public function get_insights_users(\context $context) {
if ($context->contextlevel === CONTEXT_USER) {
if (!has_capability('moodle/analytics:listowninsights', $context, $context->instanceid)) {
$users = [];
} else {
$users = [$context->instanceid => \core_user::get_user($context->instanceid)];
}
} else 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', 0, 'u.*', null, 0, 0, true);
} else {
$users = get_users_by_capability($context, 'moodle/analytics:listinsights');
}
return $users;
}
/**
* URL to the insight.
*
* @param int $modelid
* @param \context $context
* @return \moodle_url
*/
public function get_insight_context_url($modelid, $context) {
return new \moodle_url('/report/insights/insights.php?modelid=' . $modelid . '&contextid=' . $context->id);
}
/**
* The insight notification subject.
*
* This is just a default message, you should overwrite it for a custom insight message.
*
* @param int $modelid
* @param \context $context
* @return string
*/
public function get_insight_subject(int $modelid, \context $context) {
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\action[] $actions 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 &$actions) {
// No extra message by default.
return [FORMAT_PLAIN => '', FORMAT_HTML => '', 'url' => null];
}
/**
* Returns an instance of the child class.
*
* Useful to reset cached data.
*
* @return \core_analytics\base\target
*/
public static function instance() {
return new static();
}
/**
* Defines a boundary to ignore predictions below the specified prediction score.
*
* Value should go from 0 to 1.
*
* @return float
*/
protected function min_prediction_score() {
// The default minimum discards predictions with a low score.
return \core_analytics\model::PREDICTION_MIN_SCORE;
}
/**
* This method determines if a prediction is interesing for the model or not.
*
* @param mixed $predictedvalue
* @param float $predictionscore
* @return bool
*/
public function triggers_callback($predictedvalue, $predictionscore) {
$minscore = floatval($this->min_prediction_score());
if ($minscore < 0) {
debugging(get_class($this) . ' minimum prediction score is below 0, please update it to a value between 0 and 1.');
} else if ($minscore > 1) {
debugging(get_class($this) . ' minimum prediction score is above 1, please update it to a value between 0 and 1.');
}
// We need to consider that targets may not have a min score.
if (!empty($minscore) && floatval($predictionscore) < $minscore) {
return false;
}
return true;
}
/**
* Calculates the target.
*
* Returns an array of values which size matches $sampleids size.
*
* Rows with null values will be skipped as invalid by time splitting methods.
*
* @param array $sampleids
* @param \core_analytics\analysable $analysable
* @param int $starttime
* @param int $endtime
* @return array The format to follow is [userid] = scalar|null
*/
public function calculate($sampleids, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) {
if (!PHPUNIT_TEST && CLI_SCRIPT) {
echo '.';
}
$calculations = [];
foreach ($sampleids as $sampleid => $unusedsampleid) {
// No time limits when calculating the target to train models.
$calculatedvalue = $this->calculate_sample($sampleid, $analysable, $starttime, $endtime);
if (!is_null($calculatedvalue)) {
if ($this->is_linear() &&
($calculatedvalue > static::get_max_value() || $calculatedvalue < static::get_min_value())) {
throw new \coding_exception('Calculated values should be higher than ' . static::get_min_value() .
' and lower than ' . static::get_max_value() . '. ' . $calculatedvalue . ' received');
} else if (!$this->is_linear() && static::is_a_class($calculatedvalue) === false) {
throw new \coding_exception('Calculated values should be one of the target classes (' .
json_encode(static::get_classes()) . '). ' . $calculatedvalue . ' received');
}
}
$calculations[$sampleid] = $calculatedvalue;
}
return $calculations;
}
/**
* Filters out invalid samples for training.
*
* @param int[] $sampleids
* @param \core_analytics\analysable $analysable
* @param bool $fortraining
* @return void
*/
public function filter_out_invalid_samples(&$sampleids, \core_analytics\analysable $analysable, $fortraining = true) {
foreach ($sampleids as $sampleid => $unusedsampleid) {
if (!$this->is_valid_sample($sampleid, $analysable, $fortraining)) {
// Skip it and remove the sample from the list of calculated samples.
unset($sampleids[$sampleid]);
}
}
}
}
+106
View File
@@ -0,0 +1,106 @@
<?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/>.
/**
* Binary classifier target.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\target;
defined('MOODLE_INTERNAL') || die();
/**
* Binary classifier target.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class binary extends discrete {
/**
* is_linear
*
* @return bool
*/
public function is_linear() {
return false;
}
/**
* Returns the target discrete values.
*
* Only useful for targets using discrete values, must be overwriten if it is the case.
*
* @return array
*/
final public static function get_classes() {
return array(0, 1);
}
/**
* Returns the predicted classes that will be ignored.
*
* @return array
*/
public function ignored_predicted_classes() {
// Zero-value class is usually ignored in binary classifiers.
return array(0);
}
/**
* Is the calculated value a positive outcome of this target?
*
* @param string $value
* @param string $ignoredsubtype
* @return int
*/
public function get_calculation_outcome($value, $ignoredsubtype = false) {
if (!self::is_a_class($value)) {
throw new \moodle_exception('errorpredictionformat', 'analytics');
}
if (in_array($value, $this->ignored_predicted_classes(), false)) {
// Just in case, if it is ignored the prediction should not even be recorded but if it would, it is ignored now,
// which should mean that is it nothing serious.
return self::OUTCOME_VERY_POSITIVE;
}
// By default binaries are danger when prediction = 1.
if ($value) {
return self::OUTCOME_VERY_NEGATIVE;
}
return self::OUTCOME_VERY_POSITIVE;
}
/**
* classes_description
*
* @return string[]
*/
protected static function classes_description() {
return array(
get_string('yes'),
get_string('no')
);
}
}
+176
View File
@@ -0,0 +1,176 @@
<?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/>.
/**
* Discrete values target.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\target;
defined('MOODLE_INTERNAL') || die();
/**
* Discrete values target.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class discrete extends base {
/**
* Are this target calculations linear values?
*
* @return bool
*/
public function is_linear() {
// Not supported yet.
throw new \coding_exception('Sorry, this version\'s prediction processors only support targets with binary values.' .
' You can write your own and overwrite this method though.');
}
/**
* Is the provided class one of this target valid classes?
*
* @param mixed $class
* @return bool
*/
protected static function is_a_class($class) {
return (in_array($class, static::get_classes(), false));
}
/**
* get_display_value
*
* @param float $value
* @param string $ignoredsubtype
* @return string
*/
public function get_display_value($value, $ignoredsubtype = false) {
if (!self::is_a_class($value)) {
throw new \moodle_exception('errorpredictionformat', 'analytics');
}
// To discard any possible weird keys devs used.
$classes = array_values(static::get_classes());
$descriptions = array_values(static::classes_description());
if (count($classes) !== count($descriptions)) {
throw new \coding_exception('You need to describe all your classes (' . json_encode($classes) .
') in self::classes_description');
}
$key = array_search($value, $classes);
if ($key === false) {
throw new \coding_exception('You need to describe all your classes (' . json_encode($classes) .
') in self::classes_description');
}
return $descriptions[$key];
}
/**
* get_calculation_outcome
*
* @param float $value
* @param string $ignoredsubtype
* @return int
*/
public function get_calculation_outcome($value, $ignoredsubtype = false) {
if (!self::is_a_class($value)) {
throw new \moodle_exception('errorpredictionformat', 'analytics');
}
if (in_array($value, $this->ignored_predicted_classes(), false)) {
// Just in case, if it is ignored the prediction should not even be recorded.
return self::OUTCOME_OK;
}
debugging('Please overwrite \core_analytics\local\target\discrete::get_calculation_outcome, all your target ' .
'classes are styled the same way otherwise', DEBUG_DEVELOPER);
return self::OUTCOME_OK;
}
/**
* Returns all the possible values the target calculation can return.
*
* Only useful for targets using discrete values, must be overwriten if it is the case.
*
* @return array
*/
public static function get_classes() {
// Coding exception as this will only be called if this target have non-linear values.
throw new \coding_exception('Overwrite get_classes() and return an array with the different values the ' .
'target calculation can return');
}
/**
* Returns descriptions for each of the values the target calculation can return.
*
* The array indexes should match self::get_classes indexes.
*
* @return array
*/
protected static function classes_description() {
throw new \coding_exception('Overwrite classes_description() and return an array with a description for each of the ' .
'different values the target calculation can return. Indexes should match self::get_classes indexes');
}
/**
* Returns the predicted classes that will be ignored.
*
* Better be keen to add more than less classes here, the callback is always able to discard some classes. As an example
* a target with classes 'grade 0-3', 'grade 3-6', 'grade 6-8' and 'grade 8-10' is interested in flagging both 'grade 6-8'
* and 'grade 8-10' as ignored. On the other hand, a target like dropout risk with classes 'yes', 'no' may just be
* interested in 'yes'.
*
* @return array List of values that will be ignored (array keys are ignored).
*/
public function ignored_predicted_classes() {
// Coding exception as this will only be called if this target have non-linear values.
throw new \coding_exception('Overwrite ignored_predicted_classes() and return an array with the classes that should not ' .
'trigger the callback');
}
/**
* This method determines if a prediction is interesing for the model or not.
*
* This method internally calls ignored_predicted_classes to skip classes
* flagged by the target as not important for users.
*
* @param mixed $predictedvalue
* @param float $predictionscore
* @return bool
*/
public function triggers_callback($predictedvalue, $predictionscore) {
if (!parent::triggers_callback($predictedvalue, $predictionscore)) {
return false;
}
if (in_array($predictedvalue, $this->ignored_predicted_classes())) {
return false;
}
return true;
}
}
+118
View File
@@ -0,0 +1,118 @@
<?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/>.
/**
* Linear values target.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\target;
defined('MOODLE_INTERNAL') || die();
/**
* Linear values target.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class linear extends base {
/**
* Are the calculated values this target returns linear values?
*
* @return bool
*/
public function is_linear() {
// Not supported yet.
throw new \coding_exception('Sorry, this version\'s prediction processors only support targets with binary values.' .
' You can write your own and overwrite this method though.');
}
/**
* How positive is this calculated value?
*
* @param float $value
* @param string $ignoredsubtype
* @return int
*/
public function get_calculation_outcome($value, $ignoredsubtype = false) {
// This is very generic, targets will probably be interested in overwriting this.
$diff = static::get_max_value() - static::get_min_value();
if (($value - static::get_min_value()) / $diff >= 0.5) {
return self::OUTCOME_VERY_POSITIVE;
}
return self::OUTCOME_VERY_NEGATIVE;
}
/**
* Gets the maximum value for this target
*
* @return float
*/
public static function get_max_value() {
// Coding exception as this will only be called if this target have linear values.
throw new \coding_exception('Overwrite get_max_value() and return the target max value');
}
/**
* Gets the minimum value for this target
*
* @return float
*/
public static function get_min_value() {
// Coding exception as this will only be called if this target have linear values.
throw new \coding_exception('Overwrite get_min_value() and return the target min value');
}
/**
* This method determines if a prediction is interesing for the model or not.
*
* @param mixed $predictedvalue
* @param float $predictionscore
* @return bool
*/
public function triggers_callback($predictedvalue, $predictionscore) {
if (!parent::triggers_callback($predictedvalue, $predictionscore)) {
return false;
}
// People may not want to set a boundary.
$boundary = $this->get_callback_boundary();
if (!empty($boundary) && floatval($predictedvalue) < $boundary) {
return false;
}
return true;
}
/**
* Returns the minimum value that triggers the callback.
*
* @return float
*/
protected function get_callback_boundary() {
// Coding exception as this will only be called if this target have linear values.
throw new \coding_exception('Overwrite get_callback_boundary() and return the min value that ' .
'should trigger the callback');
}
}
@@ -0,0 +1,72 @@
<?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/>.
/**
* Range processor splitting the course in parts and accumulating data from the start.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* Range processor splitting the course in parts and accumulating data from the start.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class accumulative_parts extends base implements before_now {
/**
* The number of parts to split the analysable duration in.
*
* @return int
*/
abstract protected function get_number_parts();
/**
* define_ranges
*
* @return array
*/
protected function define_ranges() {
$nparts = $this->get_number_parts();
$rangeduration = ($this->analysable->get_end() - $this->analysable->get_start()) / $nparts;
$ranges = array();
for ($i = 0; $i < $nparts; $i++) {
$end = $this->analysable->get_start() + intval($rangeduration * ($i + 1));
if ($i === ($nparts - 1)) {
// Better to use the end for the last one as we are using floor above.
$end = $this->analysable->get_end();
}
$ranges[$i] = array(
'start' => $this->analysable->get_start(),
'end' => $end,
'time' => $end
);
}
return $ranges;
}
}
@@ -0,0 +1,37 @@
<?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/>.
/**
* Interface for time-splitting methods whose ranges' times are after time().
*
* @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\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* Interface for time-splitting methods whose ranges' times are after time().
*
* @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
*/
interface after_now {
}
@@ -0,0 +1,111 @@
<?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/>.
/**
* Time splitting method that generates predictions X days/weeks/months after the analysable start.
*
* @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\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* Time splitting method that generates predictions X days/weeks/months after the analysable start.
*
* @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
*/
abstract class after_start extends \core_analytics\local\time_splitting\base implements before_now {
/**
* The period we should wait until we generate predictions for this.
*
* @param \core_analytics\analysable $analysable
* @return \DateInterval
*/
abstract protected function wait_period(\core_analytics\analysable $analysable);
/**
* Returns whether the course can be processed by this time splitting method or not.
*
* @param \core_analytics\analysable $analysable
* @return bool
*/
public function is_valid_analysable(\core_analytics\analysable $analysable) {
if (!$analysable->get_start()) {
return false;
}
$predictionstart = $this->get_prediction_interval_start($analysable);
if ($analysable->get_start() > $predictionstart) {
// We still need to wait.
return false;
}
return true;
}
/**
* This time-splitting method returns one single range, the start to two days before the end.
*
* @return array The list of ranges, each of them including 'start', 'end' and 'time'
*/
protected function define_ranges() {
$now = time();
$ranges = [
[
'start' => $this->analysable->get_start(),
'end' => $now,
'time' => $now,
]
];
return $ranges;
}
/**
* Whether to cache or not the indicator calculations.
*
* @return bool
*/
public function cache_indicator_calculations(): bool {
return false;
}
/**
* Calculates the interval start time backwards, from now.
*
* @param \core_analytics\analysable $analysable
* @return int
*/
protected function get_prediction_interval_start(\core_analytics\analysable $analysable) {
// The prediction time is always time(). We don't want to reuse the firstanalysis time
// because otherwise samples (e.g. students) which start after the analysable (e.g. course)
// start would use an incorrect analysis interval.
$predictionstart = new \DateTime('now');
$predictionstart->sub($this->wait_period($analysable));
return $predictionstart->getTimestamp();
}
}
@@ -0,0 +1,311 @@
<?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/>.
/**
* Base time splitting method.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* Base time splitting method.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class base {
/**
* @var string
*/
protected $id;
/**
* The model id.
*
* @var int
*/
protected $modelid;
/**
* @var \core_analytics\analysable
*/
protected $analysable;
/**
* @var array
*/
protected $ranges = [];
/**
* Define the time splitting methods ranges.
*
* 'time' value defines when predictions are executed, their values will be compared with
* the current time in ready_to_predict. The ranges should be sorted by 'time' in
* ascending order.
*
* @return array('start' => time(), 'end' => time(), 'time' => time())
*/
abstract protected function define_ranges();
/**
* Returns a lang_string object representing the name for the time splitting method.
*
* Used as column identificator.
*
* If there is a corresponding '_help' string this will be shown as well.
*
* @return \lang_string
*/
abstract public static function get_name(): \lang_string;
/**
* Returns the time splitting method id.
*
* @return string
*/
public function get_id() {
return '\\' . get_class($this);
}
/**
* Assigns the analysable and updates the time ranges according to the analysable start and end dates.
*
* @param \core_analytics\analysable $analysable
* @return void
*/
public function set_analysable(\core_analytics\analysable $analysable) {
$this->analysable = $analysable;
$this->ranges = $this->define_ranges();
$this->validate_ranges();
}
/**
* Assigns the model id to this time-splitting method it case it needs it.
*
* @param int $modelid
*/
public function set_modelid(int $modelid) {
$this->modelid = $modelid;
}
/**
* get_analysable
*
* @return \core_analytics\analysable
*/
public function get_analysable() {
return $this->analysable;
}
/**
* Returns whether the course can be processed by this time splitting method or not.
*
* @param \core_analytics\analysable $analysable
* @return bool
*/
public function is_valid_analysable(\core_analytics\analysable $analysable) {
return true;
}
/**
* Should we predict this time range now?
*
* @param array $range
* @return bool
*/
public function ready_to_predict($range) {
if ($range['time'] <= time()) {
return true;
}
return false;
}
/**
* Should we use this time range for training?
*
* @param array $range
* @return bool
*/
public function ready_to_train($range) {
$now = time();
if ($range['time'] <= $now && $range['end'] <= $now) {
return true;
}
return false;
}
/**
* Returns the ranges used by this time splitting method.
*
* @return array
*/
public function get_all_ranges() {
return $this->ranges;
}
/**
* By default all ranges are for training.
*
* @return array
*/
public function get_training_ranges() {
return $this->ranges;
}
/**
* Returns the distinct range indexes in this time splitting method.
*
* @return int[]
*/
public function get_distinct_ranges() {
if ($this->include_range_info_in_training_data()) {
return array_keys($this->ranges);
} else {
return [0];
}
}
/**
* Returns the most recent range that can be used to predict.
*
* This method is only called when calculating predictions.
*
* @return array
*/
public function get_most_recent_prediction_range() {
$ranges = $this->get_all_ranges();
// Opposite order as we are interested in the last range that can be used for prediction.
krsort($ranges);
// We already provided the analysable to the time splitting method, there is no need to feed it back.
foreach ($ranges as $rangeindex => $range) {
if ($this->ready_to_predict($range)) {
// We need to maintain the same indexes.
return array($rangeindex => $range);
}
}
return array();
}
/**
* Returns range data by its index.
*
* @param int $rangeindex
* @return array|false Range data or false if the index is not part of the existing ranges.
*/
public function get_range_by_index($rangeindex) {
if (!isset($this->ranges[$rangeindex])) {
return false;
}
return $this->ranges[$rangeindex];
}
/**
* Generates a unique sample id (sample in a range index).
*
* @param int $sampleid
* @param int $rangeindex
* @return string
*/
final public function append_rangeindex($sampleid, $rangeindex) {
return $sampleid . '-' . $rangeindex;
}
/**
* Returns the sample id and the range index from a uniquesampleid.
*
* @param string $uniquesampleid
* @return array array($sampleid, $rangeindex)
*/
final public function infer_sample_info($uniquesampleid) {
return explode('-', $uniquesampleid);
}
/**
* Whether to include the range index in the training data or not.
*
* By default, we consider that the different time ranges included in a time splitting method may not be
* compatible between them (i.e. the indicators calculated at the end of the course can easily
* differ from indicators calculated at the beginning of the course). So we include the range index as
* one of the variables that the machine learning backend uses to generate predictions.
*
* If the indicators calculated using the different time ranges available in this time splitting method
* are comparable you can overwrite this method to return false.
*
* Note that:
* - This is only relevant for models whose predictions are not based on assumptions
* (i.e. the ones using a machine learning backend to generate predictions).
* - The ranges can only be included in the training data when
* we know the final number of ranges the time splitting method will have. E.g.
* We can not know the final number of ranges of a 'daily' time splitting method
* as we will have one new range every day.
* @return bool
*/
public function include_range_info_in_training_data() {
return true;
}
/**
* Whether to cache or not the indicator calculations.
*
* Indicator calculations are stored to be reused across models. The calculations
* are indexed by the calculation start and end time, and these times depend on the
* time-splitting method. You should overwrite this method and return false if the time
* frames generated by your time-splitting method are unique and / or can hardly be
* reused by further models.
*
* @return bool
*/
public function cache_indicator_calculations(): bool {
return true;
}
/**
* Is this method valid to evaluate prediction models?
*
* @return bool
*/
public function valid_for_evaluation(): bool {
return true;
}
/**
* Validates the time splitting method ranges.
*
* @throws \coding_exception
* @return void
*/
protected function validate_ranges() {
foreach ($this->ranges as $key => $range) {
if (!isset($this->ranges[$key]['start']) || !isset($this->ranges[$key]['end']) ||
!isset($this->ranges[$key]['time'])) {
throw new \coding_exception($this->get_id() . ' time splitting method "' . $key .
'" range is not fully defined. We need a start timestamp and an end timestamp.');
}
}
}
}
@@ -0,0 +1,37 @@
<?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/>.
/**
* Interface for time-splitting methods whose ranges' times are before time().
*
* @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\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* Interface for time-splitting methods whose ranges' times are before time().
*
* @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
*/
interface before_now {
}
@@ -0,0 +1,88 @@
<?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/>.
/**
* X parts time splitting method.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* X parts time splitting method.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class equal_parts extends base implements before_now {
/**
* Returns the number of parts the analyser duration should be split in.
*
* @return int
*/
abstract protected function get_number_parts();
/**
* Splits the analysable duration in X equal parts from the start to the end.
*
* @return array
*/
protected function define_ranges() {
$nparts = $this->get_number_parts();
$rangeduration = ($this->analysable->get_end() - $this->analysable->get_start()) / $nparts;
if ($rangeduration < $nparts) {
// It is interesting to avoid having a single timestamp belonging to multiple time ranges
// because of things like community of inquiry indicators, where activities have a due date
// that, ideally, would fall only into 1 time range. If the analysable duration is very short
// it is because the model doesn't contain indicators that depend so heavily on time and therefore
// we don't need to worry about timestamps being present in multiple time ranges.
$allowmultipleranges = true;
}
$ranges = array();
for ($i = 0; $i < $nparts; $i++) {
$start = $this->analysable->get_start() + intval($rangeduration * $i);
$end = $this->analysable->get_start() + intval($rangeduration * ($i + 1));
if (empty($allowmultipleranges) && $i > 0 && $start === $ranges[$i - 1]['end']) {
// We add 1 second so each timestamp only belongs to 1 range.
$start = $start + 1;
}
if ($i === ($nparts - 1)) {
// Better to use the end for the last one as we are using floor above.
$end = $this->analysable->get_end();
}
$ranges[$i] = array(
'start' => $start,
'end' => $end,
'time' => $end
);
}
return $ranges;
}
}
@@ -0,0 +1,97 @@
<?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/>.
/**
* Time splitting method that generates predictions regularly.
*
* @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\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* Time splitting method that generates predictions periodically.
*
* @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
*/
abstract class past_periodic extends periodic implements before_now {
/**
* Gets the next range with start on the provided time.
*
* The next range is based on the past period so we substract this
* range's periodicity from $time.
*
* @param \DateTimeImmutable $time
* @return array
*/
protected function get_next_range(\DateTimeImmutable $time) {
$end = $time->getTimestamp();
$start = $time->sub($this->periodicity())->getTimestamp();
if ($start < $this->analysable->get_start()) {
// We skip the first range generated as its start is prior to the analysable start.
return false;
}
return [
'start' => $start,
'end' => $end,
'time' => $end
];
}
/**
* Get the start of the first time range.
*
* @return int A timestamp.
*/
protected function get_first_start() {
return $this->analysable->get_start();
}
/**
* Guarantees that the last range dates end right now.
*
* @param array $ranges
* @return array
*/
protected function update_last_range(array $ranges) {
$lastrange = end($ranges);
if ($lastrange['time'] > time()) {
// We just need to wait in this case.
return $lastrange;
}
$timetoenddiff = time() - $lastrange['time'];
$ranges[count($ranges) - 1] = [
'start' => $lastrange['start'] + $timetoenddiff,
'end' => $lastrange['end'] + $timetoenddiff,
'time' => $lastrange['time'] + $timetoenddiff,
];
return $ranges;
}
}
@@ -0,0 +1,162 @@
<?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/>.
/**
* Time splitting method that generates predictions regularly.
*
* @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\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* Time splitting method that generates predictions periodically.
*
* @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
*/
abstract class periodic extends base {
/**
* The periodicity of the predictions / training data generation.
*
* @return \DateInterval
*/
abstract protected function periodicity();
/**
* Gets the next range with start on the provided time.
*
* @param \DateTimeImmutable $time
* @return array
*/
abstract protected function get_next_range(\DateTimeImmutable $time);
/**
* Get the start of the first time range.
*
* @return int A timestamp.
*/
abstract protected function get_first_start();
/**
* Returns whether the analysable can be processed by this time splitting method or not.
*
* @param \core_analytics\analysable $analysable
* @return bool
*/
public function is_valid_analysable(\core_analytics\analysable $analysable) {
if (!$analysable->get_start()) {
return false;
}
return true;
}
/**
* define_ranges
*
* @return array
*/
protected function define_ranges() {
$periodicity = $this->periodicity();
if ($this->analysable->get_end()) {
$end = (new \DateTimeImmutable())->setTimestamp($this->analysable->get_end());
}
$nexttime = (new \DateTimeImmutable())->setTimestamp($this->get_first_start());
$now = new \DateTimeImmutable('now', \core_date::get_server_timezone_object());
$range = $this->get_next_range($nexttime);
if (!$range) {
$nexttime = $nexttime->add($periodicity);
$range = $this->get_next_range($nexttime);
if (!$range) {
throw new \coding_exception('The get_next_range implementation is broken. The difference between two consecutive
ranges can not be more than the periodicity.');
}
}
$ranges = [];
$endreached = false;
while (($this->ready_to_predict($range) || $this->ready_to_train($range)) && !$endreached) {
$ranges[] = $range;
$nexttime = $nexttime->add($periodicity);
$range = $this->get_next_range($nexttime);
$endreached = (!empty($end) && $nexttime > $end);
}
if ($ranges && !$endreached) {
// If this analysable is not finished we adjust the start and end of the last element in $ranges
// so that it ends in time().The reason is that the start of these ranges is based on the analysable
// start and the end is calculated based on the start. This is to prevent the same issue we had in MDL-65348.
//
// An example of the situation we want to avoid is:
// A course started on a Monday, in 2015. It has no end date. Now the system is upgraded to Moodle 3.8, which
// includes this code. This happens on Wednesday. Periodic ranges (e.g. weekly) will be calculated from a Monday
// so the data provided by the time-splitting method would be from Monday to Monday, when we really want to
// provide data from Wednesday to the past Wednesday.
$ranges = $this->update_last_range($ranges);
}
return $ranges;
}
/**
* Overwritten as all generated rows are comparable.
*
* @return bool
*/
public function include_range_info_in_training_data() {
return false;
}
/**
* Overwritting as the last range may be for prediction.
*
* @return array
*/
public function get_training_ranges() {
// Cloning the array.
$trainingranges = $this->ranges;
foreach ($trainingranges as $rangeindex => $range) {
if (!$this->ready_to_train($range)) {
unset($trainingranges[$rangeindex]);
}
}
return $trainingranges;
}
/**
* Allows child classes to update the last range provided.
*
* @param array $ranges
* @return array
*/
protected function update_last_range(array $ranges) {
return $ranges;
}
}
@@ -0,0 +1,96 @@
<?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/>.
/**
* Time splitting method that generates predictions periodically.
*
* @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\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* Time splitting method that generates predictions periodically.
*
* @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
*/
abstract class upcoming_periodic extends periodic implements after_now {
/**
* Gets the next range with start on the provided time.
*
* The next range is based on the upcoming period so we add this
* range's periodicity to $time.
*
* @param \DateTimeImmutable $time
* @return array
*/
protected function get_next_range(\DateTimeImmutable $time) {
$start = $time->getTimestamp();
$end = $time->add($this->periodicity())->getTimestamp();
return [
'start' => $start,
'end' => $end,
'time' => $start
];
}
/**
* Whether to cache or not the indicator calculations.
* @return bool
*/
public function cache_indicator_calculations(): bool {
return false;
}
/**
* Overriden as these time-splitting methods are based on future dates.
*
* @return bool
*/
public function valid_for_evaluation(): bool {
return false;
}
/**
* Get the start of the first time range.
*
* Overwriten to start generating predictions about upcoming stuff from time().
*
* @return int A timestamp.
*/
protected function get_first_start() {
global $DB;
$cache = \cache::make('core', 'modelfirstanalyses');
$key = $this->modelid . '_' . $this->analysable->get_id();
$firstanalysis = $cache->get($key);
if (!empty($firstanalysis)) {
return $firstanalysis;
}
// This analysable has not yet been analysed, the start is therefore now.
return time();
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+327
View File
@@ -0,0 +1,327 @@
<?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/>.
/**
* Model configuration manager.
*
* @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();
/**
* Model configuration manager.
*
* @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 model_config {
/**
* @var \core_analytics\model
*/
private $model = null;
/**
* The name of the file where config is held.
*/
const CONFIG_FILE_NAME = 'model-config.json';
/**
* Constructor.
*
* @param \core_analytics\model|null $model
*/
public function __construct(?model $model = null) {
$this->model = $model;
}
/**
* Exports a model to a zip using the provided file name.
*
* @param string $zipfilename
* @param bool $includeweights Include the model weights if available
* @return string
*/
public function export(string $zipfilename, bool $includeweights = true): string {
if (!$this->model) {
throw new \coding_exception('No model object provided.');
}
if (!$this->model->can_export_configuration()) {
throw new \moodle_exception('errornoexportconfigrequirements', 'analytics');
}
$zip = new \zip_packer();
$zipfiles = [];
// Model config in JSON.
$modeldata = $this->export_model_data();
$exporttmpdir = make_request_directory();
$jsonfilepath = $exporttmpdir . DIRECTORY_SEPARATOR . 'model-config.json';
if (!file_put_contents($jsonfilepath, json_encode($modeldata))) {
throw new \moodle_exception('errornoexportconfig', 'analytics');
}
$zipfiles[self::CONFIG_FILE_NAME] = $jsonfilepath;
// ML backend.
if ($includeweights && $this->model->is_trained()) {
$processor = $this->model->get_predictions_processor(true);
$outputdir = $this->model->get_output_dir(array('execution'));
$mlbackenddir = $processor->export($this->model->get_unique_id(), $outputdir);
$mlbackendfiles = get_directory_list($mlbackenddir);
foreach ($mlbackendfiles as $mlbackendfile) {
$fullpath = $mlbackenddir . DIRECTORY_SEPARATOR . $mlbackendfile;
// Place the ML backend files inside a mlbackend/ dir.
$zipfiles['mlbackend/' . $mlbackendfile] = $fullpath;
}
}
$zipfilepath = $exporttmpdir . DIRECTORY_SEPARATOR . $zipfilename;
$zip->archive_to_pathname($zipfiles, $zipfilepath);
return $zipfilepath;
}
/**
* Imports the provided model configuration into a new model.
*
* Note that this method assumes that self::check_dependencies has already been called.
*
* @param string $zipfilepath Path to the zip file to import
* @return \core_analytics\model
*/
public function import(string $zipfilepath): \core_analytics\model {
list($modeldata, $mlbackenddir) = $this->extract_import_contents($zipfilepath);
$target = \core_analytics\manager::get_target($modeldata->target);
$indicators = [];
foreach ($modeldata->indicators as $indicatorclass) {
$indicator = \core_analytics\manager::get_indicator($indicatorclass);
$indicators[$indicator->get_id()] = $indicator;
}
$model = \core_analytics\model::create($target, $indicators, $modeldata->timesplitting, $modeldata->processor);
// Import them disabled.
$model->update(false, false, false, false);
if ($mlbackenddir) {
$modeldir = $model->get_output_dir(['execution']);
if (!$model->get_predictions_processor(true)->import($model->get_unique_id(), $modeldir, $mlbackenddir)) {
throw new \moodle_exception('errorimport', 'analytics');
}
$model->mark_as_trained();
}
return $model;
}
/**
* Check that the provided model configuration can be deployed in this site.
*
* @param \stdClass $modeldata
* @param bool $ignoreversionmismatches
* @return string|null Error string or null if all good.
*/
public function check_dependencies(\stdClass $modeldata, bool $ignoreversionmismatches): ?string {
$siteversions = \core_component::get_all_versions();
// Possible issues.
$missingcomponents = [];
$versionmismatches = [];
$missingclasses = [];
// We first check that this site has the required dependencies and the required versions.
foreach ($modeldata->dependencies as $component => $importversion) {
if (empty($siteversions[$component])) {
if ($component === 'core') {
$component = 'Moodle';
}
$missingcomponents[$component] = $component . ' (' . $importversion . ')';
continue;
}
if ($siteversions[$component] == $importversion) {
// All good here.
continue;
}
if (!$ignoreversionmismatches) {
if ($component === 'core') {
$component = 'Moodle';
}
$versionmismatches[$component] = $component . ' (' . $importversion . ')';
}
}
// Checking that each of the components is available.
if (!$target = manager::get_target($modeldata->target)) {
$missingclasses[] = $modeldata->target;
}
if (!$timesplitting = manager::get_time_splitting($modeldata->timesplitting)) {
$missingclasses[] = $modeldata->timesplitting;
}
// Indicators.
foreach ($modeldata->indicators as $indicatorclass) {
if (!$indicator = manager::get_indicator($indicatorclass)) {
$missingclasses[] = $indicatorclass;
}
}
// ML backend.
if (!empty($modeldata->processor)) {
if (!$processor = \core_analytics\manager::get_predictions_processor($modeldata->processor, false)) {
$missingclasses[] = $indicatorclass;
}
}
if (!empty($missingcomponents)) {
return get_string('errorimportmissingcomponents', 'analytics', join(', ', $missingcomponents));
}
if (!empty($versionmismatches)) {
return get_string('errorimportversionmismatches', 'analytics', implode(', ', $versionmismatches));
}
if (!empty($missingclasses)) {
$a = (object)[
'missingclasses' => implode(', ', $missingclasses),
];
return get_string('errorimportmissingclasses', 'analytics', $a);
}
// No issues found.
return null;
}
/**
* Returns the component the class belongs to.
*
* Note that this method does not work for global space classes.
*
* @param string $fullclassname Qualified name including the namespace.
* @return string|null Frankenstyle component
*/
public static function get_class_component(string $fullclassname): ?string {
// Strip out leading backslash.
$fullclassname = ltrim($fullclassname, '\\');
$nextbackslash = strpos($fullclassname, '\\');
if ($nextbackslash === false) {
// Global space.
return 'core';
}
$component = substr($fullclassname, 0, $nextbackslash);
// All core subsystems use core's version.php.
if (strpos($component, 'core_') === 0) {
$component = 'core';
}
return $component;
}
/**
* Extracts the import zip contents.
*
* @param string $zipfilepath Zip file path
* @return array [0] => \stdClass, [1] => string
*/
public function extract_import_contents(string $zipfilepath): array {
$importtempdir = make_request_directory();
$zip = new \zip_packer();
$filelist = $zip->extract_to_pathname($zipfilepath, $importtempdir);
if (empty($filelist[self::CONFIG_FILE_NAME])) {
// Missing required file.
throw new \moodle_exception('errorimport', 'analytics');
}
$jsonmodeldata = file_get_contents($importtempdir . DIRECTORY_SEPARATOR . self::CONFIG_FILE_NAME);
if (!$modeldata = json_decode($jsonmodeldata)) {
throw new \moodle_exception('errorimport', 'analytics');
}
if (empty($modeldata->target) || empty($modeldata->timesplitting) || empty($modeldata->indicators)) {
throw new \moodle_exception('errorimport', 'analytics');
}
$mlbackenddir = $importtempdir . DIRECTORY_SEPARATOR . 'mlbackend';
if (!is_dir($mlbackenddir)) {
$mlbackenddir = false;
}
return [$modeldata, $mlbackenddir];
}
/**
* Exports the configuration of the model.
* @return \stdClass
*/
protected function export_model_data(): \stdClass {
$versions = \core_component::get_all_versions();
$data = new \stdClass();
// Target.
$data->target = $this->model->get_target()->get_id();
$requiredclasses[] = $data->target;
// Time splitting method.
$data->timesplitting = $this->model->get_time_splitting()->get_id();
$requiredclasses[] = $data->timesplitting;
// Model indicators.
$data->indicators = [];
foreach ($this->model->get_indicators() as $indicator) {
$indicatorid = $indicator->get_id();
$data->indicators[] = $indicatorid;
$requiredclasses[] = $indicatorid;
}
// Return the predictions processor this model is using, even if no predictions processor
// was explicitly selected.
$predictionsprocessor = $this->model->get_predictions_processor();
$data->processor = '\\' . get_class($predictionsprocessor);
$requiredclasses[] = $data->processor;
// Add information for versioning.
$data->dependencies = [];
foreach ($requiredclasses as $fullclassname) {
$component = $this->get_class_component($fullclassname);
$data->dependencies[$component] = $versions[$component];
}
return $data;
}
}
+57
View File
@@ -0,0 +1,57 @@
<?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/>.
/**
* Exportable machine learning backend interface.
*
* @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();
/**
* Exportable machine learning backend interface.
*
* @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
*/
interface packable {
/**
* Exports the machine learning model.
*
* @throws \moodle_exception
* @param string $uniqueid The model unique id
* @param string $modeldir The directory that contains the trained model.
* @return string The path to the directory that contains the exported model.
*/
public function export(string $uniqueid, string $modeldir): string;
/**
* Imports the provided machine learning model.
*
* @param string $uniqueid The model unique id
* @param string $modeldir The directory that will contain the trained model.
* @param string $importdir The directory that contains the files to import.
* @return bool Success
*/
public function import(string $uniqueid, string $modeldir, string $importdir): bool;
}
+255
View File
@@ -0,0 +1,255 @@
<?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/>.
/**
* Representation of a prediction.
*
* @package core_analytics
* @copyright 2017 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();
/**
* Representation of a prediction.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class prediction {
/**
* Prediction details (one of the default prediction actions)
*/
const ACTION_PREDICTION_DETAILS = 'predictiondetails';
/**
* Prediction useful (one of the default prediction actions)
*/
const ACTION_USEFUL = 'useful';
/**
* Prediction not useful (one of the default prediction actions)
*/
const ACTION_NOT_USEFUL = 'notuseful';
/**
* Prediction already fixed (one of the default prediction actions)
*/
const ACTION_FIXED = 'fixed';
/**
* Prediction not applicable.
*/
const ACTION_NOT_APPLICABLE = 'notapplicable';
/**
* Prediction incorrectly flagged.
*/
const ACTION_INCORRECTLY_FLAGGED = 'incorrectlyflagged';
/**
* @var \stdClass
*/
private $prediction;
/**
* @var array
*/
private $sampledata;
/**
* @var array
*/
private $calculations = array();
/**
* Constructor
*
* @param \stdClass|int $prediction
* @param array $sampledata
* @return void
*/
public function __construct($prediction, $sampledata) {
global $DB;
if (is_scalar($prediction)) {
$prediction = $DB->get_record('analytics_predictions', array('id' => $prediction), '*', MUST_EXIST);
}
$this->prediction = $prediction;
$this->sampledata = $sampledata;
$this->format_calculations();
}
/**
* Get prediction object data.
*
* @return \stdClass
*/
public function get_prediction_data() {
return $this->prediction;
}
/**
* Get prediction sample data.
*
* @return array
*/
public function get_sample_data() {
return $this->sampledata;
}
/**
* Gets the prediction calculations
*
* @return array
*/
public function get_calculations() {
return $this->calculations;
}
/**
* Stores the executed action.
* Prediction instances should be retrieved using \core_analytics\manager::get_prediction,
* It is the caller responsability to check that the user can see the prediction.
*
* @param string $actionname
* @param \core_analytics\local\target\base $target
*/
public function action_executed($actionname, \core_analytics\local\target\base $target) {
global $USER, $DB;
$context = \context::instance_by_id($this->get_prediction_data()->contextid, IGNORE_MISSING);
if (!$context) {
throw new \moodle_exception('errorpredictioncontextnotavailable', 'analytics');
}
// Check that the provided action exists.
$actions = $target->prediction_actions($this, true);
foreach ($actions as $action) {
if ($action->get_action_name() === $actionname) {
$found = true;
}
}
$bulkactions = $target->bulk_actions([$this]);
foreach ($bulkactions as $action) {
if ($action->get_action_name() === $actionname) {
$found = true;
}
}
if (empty($found)) {
throw new \moodle_exception('errorunknownaction', 'analytics');
}
$predictionid = $this->get_prediction_data()->id;
$action = new \stdClass();
$action->predictionid = $predictionid;
$action->userid = $USER->id;
$action->actionname = $actionname;
$action->timecreated = time();
$DB->insert_record('analytics_prediction_actions', $action);
$eventdata = array (
'context' => $context,
'objectid' => $predictionid,
'other' => array('actionname' => $actionname)
);
\core\event\prediction_action_started::create($eventdata)->trigger();
}
/**
* Get the executed actions.
*
* Actions could be filtered by actionname.
*
* @param array $actionnamefilter Limit the results obtained to this list of action names.
* @param int $userid the user id. Current user by default.
* @return array of actions.
*/
public function get_executed_actions(array $actionnamefilter = null, int $userid = 0): array {
global $USER, $DB;
$conditions[] = "predictionid = :predictionid";
$params['predictionid'] = $this->get_prediction_data()->id;
if (!$userid) {
$userid = $USER->id;
}
$conditions[] = "userid = :userid";
$params['userid'] = $userid;
if ($actionnamefilter) {
list($actionsql, $actionparams) = $DB->get_in_or_equal($actionnamefilter, SQL_PARAMS_NAMED);
$conditions[] = "actionname $actionsql";
$params = $params + $actionparams;
}
return $DB->get_records_select('analytics_prediction_actions', implode(' AND ', $conditions), $params);
}
/**
* format_calculations
*
* @return \stdClass[]
*/
private function format_calculations() {
$calculations = json_decode($this->prediction->calculations, true);
foreach ($calculations as $featurename => $value) {
list($indicatorclass, $subtype) = $this->parse_feature_name($featurename);
if ($indicatorclass === 'range') {
// Time range indicators don't belong to any indicator class, we don't store them.
continue;
} else if (!\core_analytics\manager::is_valid($indicatorclass, '\core_analytics\local\indicator\base')) {
throw new \moodle_exception('errorpredictionformat', 'analytics');
}
$this->calculations[$featurename] = new \stdClass();
$this->calculations[$featurename]->subtype = $subtype;
$this->calculations[$featurename]->indicator = \core_analytics\manager::get_indicator($indicatorclass);
$this->calculations[$featurename]->value = $value;
}
}
/**
* parse_feature_name
*
* @param string $featurename
* @return string[]
*/
private function parse_feature_name($featurename) {
$indicatorclass = $featurename;
$subtype = false;
// Some indicator result in more than 1 feature, we need to see which feature are we dealing with.
$separatorpos = strpos($featurename, '/');
if ($separatorpos !== false) {
$subtype = substr($featurename, ($separatorpos + 1));
$indicatorclass = substr($featurename, 0, $separatorpos);
}
return array($indicatorclass, $subtype);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?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/>.
/**
* Representation of a suggested action associated with a prediction.
*
* @package core_analytics
* @copyright 2017 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();
/**
* Representation of a suggested action associated with a prediction.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class prediction_action extends action {
/**
* Prediction action constructor.
*
* @param string $actionname They should match a-zA-Z_0-9-, as we apply a PARAM_ALPHANUMEXT filter
* @param \core_analytics\prediction $prediction
* @param \moodle_url $actionurl The final URL where the user should be forwarded.
* @param \pix_icon $icon Link icon
* @param string $text Link text
* @param bool $primary Primary button or secondary.
* @param array $attributes Link attributes
* @param string|false $type
* @return void
*/
public function __construct($actionname, \core_analytics\prediction $prediction, \moodle_url $actionurl, \pix_icon $icon,
$text, $primary = false, $attributes = array(), $type = false) {
$this->actionname = $actionname;
$this->text = $text;
$this->set_type($type);
$this->url = self::transform_to_forward_url($actionurl, $actionname, $prediction->get_prediction_data()->id);
// The \action_menu_link items are displayed as an icon with a label, no need to show any text.
if ($primary === false) {
$this->actionlink = new \action_menu_link_secondary($this->url, $icon, '', $attributes);
} else {
$this->actionlink = new \action_menu_link_primary($this->url, $icon, '', $attributes);
}
}
/**
* 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);
}
}
+78
View File
@@ -0,0 +1,78 @@
<?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/>.
/**
* Predictions processor interface.
*
* @package core_analytics
* @copyright 2017 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();
/**
* Predictors interface.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
interface predictor {
/**
* Is it ready to predict?
*
* @return bool
*/
public function is_ready();
/**
* Delete all stored information of the current model id.
*
* This method is called when there are important changes to a model,
* all previous training algorithms using that version of the model
* should be deleted.
*
* In case you want to perform extra security measures before deleting
* a directory you can check that $modelversionoutputdir subdirectories
* can only be named 'execution', 'evaluation' or 'testing'.
*
* @param string $uniqueid The site model unique id string
* @param string $modelversionoutputdir The output dir of this model version
* @return null
*/
public function clear_model($uniqueid, $modelversionoutputdir);
/**
* Delete the output directory.
*
* This method is called when a model is completely deleted.
*
* In case you want to perform extra security measures before deleting
* a directory you can check that the subdirectories are timestamps
* (the model version) and each of this subdirectories' subdirectories
* can only be named 'execution', 'evaluation' or 'testing'.
*
* @param string $modeloutputdir The model directory id (parent of all model versions subdirectories).
* @param string $uniqueid
* @return null
*/
public function delete_output_dir($modeloutputdir, $uniqueid);
}
+524
View File
@@ -0,0 +1,524 @@
<?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/>.
/**
* Privacy Subsystem implementation for core_analytics.
*
* @package core_analytics
* @copyright 2018 David Monllaó
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\privacy;
use core_privacy\local\request\transform;
use core_privacy\local\request\writer;
use core_privacy\local\metadata\collection;
use core_privacy\local\request\approved_contextlist;
use core_privacy\local\request\approved_userlist;
use core_privacy\local\request\context;
use core_privacy\local\request\contextlist;
use core_privacy\local\request\userlist;
defined('MOODLE_INTERNAL') || die();
/**
* Privacy Subsystem for core_analytics implementing metadata and plugin providers.
*
* @copyright 2018 David Monllaó
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider implements
\core_privacy\local\metadata\provider,
\core_privacy\local\request\core_userlist_provider,
\core_privacy\local\request\plugin\provider {
/**
* Returns meta data about this system.
*
* @param collection $collection The initialised collection to add items to.
* @return collection A listing of user data stored through this system.
*/
public static function get_metadata(collection $collection): collection {
$collection->add_database_table(
'analytics_indicator_calc',
[
'starttime' => 'privacy:metadata:analytics:indicatorcalc:starttime',
'endtime' => 'privacy:metadata:analytics:indicatorcalc:endtime',
'contextid' => 'privacy:metadata:analytics:indicatorcalc:contextid',
'sampleorigin' => 'privacy:metadata:analytics:indicatorcalc:sampleorigin',
'sampleid' => 'privacy:metadata:analytics:indicatorcalc:sampleid',
'indicator' => 'privacy:metadata:analytics:indicatorcalc:indicator',
'value' => 'privacy:metadata:analytics:indicatorcalc:value',
'timecreated' => 'privacy:metadata:analytics:indicatorcalc:timecreated',
],
'privacy:metadata:analytics:indicatorcalc'
);
$collection->add_database_table(
'analytics_predictions',
[
'modelid' => 'privacy:metadata:analytics:predictions:modelid',
'contextid' => 'privacy:metadata:analytics:predictions:contextid',
'sampleid' => 'privacy:metadata:analytics:predictions:sampleid',
'rangeindex' => 'privacy:metadata:analytics:predictions:rangeindex',
'prediction' => 'privacy:metadata:analytics:predictions:prediction',
'predictionscore' => 'privacy:metadata:analytics:predictions:predictionscore',
'calculations' => 'privacy:metadata:analytics:predictions:calculations',
'timecreated' => 'privacy:metadata:analytics:predictions:timecreated',
'timestart' => 'privacy:metadata:analytics:predictions:timestart',
'timeend' => 'privacy:metadata:analytics:predictions:timeend',
],
'privacy:metadata:analytics:predictions'
);
$collection->add_database_table(
'analytics_prediction_actions',
[
'predictionid' => 'privacy:metadata:analytics:predictionactions:predictionid',
'userid' => 'privacy:metadata:analytics:predictionactions:userid',
'actionname' => 'privacy:metadata:analytics:predictionactions:actionname',
'timecreated' => 'privacy:metadata:analytics:predictionactions:timecreated',
],
'privacy:metadata:analytics:predictionactions'
);
// Regarding this block, we are unable to export or purge this data, as
// it would damage the analytics data across the whole site.
$collection->add_database_table(
'analytics_models',
[
'usermodified' => 'privacy:metadata:analytics:analyticsmodels:usermodified',
],
'privacy:metadata:analytics:analyticsmodels'
);
// Regarding this block, we are unable to export or purge this data, as
// it would damage the analytics log data across the whole site.
$collection->add_database_table(
'analytics_models_log',
[
'usermodified' => 'privacy:metadata:analytics:analyticsmodelslog:usermodified',
],
'privacy:metadata:analytics:analyticsmodelslog'
);
return $collection;
}
/**
* Get the list of contexts that contain user information for the specified user.
*
* @param int $userid The user to search.
* @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
*/
public static function get_contexts_for_userid(int $userid): contextlist {
global $DB;
$contextlist = new \core_privacy\local\request\contextlist();
$models = self::get_models_with_user_data();
foreach ($models as $modelid => $model) {
$analyser = $model->get_analyser(['notimesplitting' => true]);
// Analytics predictions.
$joinusersql = $analyser->join_sample_user('ap');
$sql = "SELECT DISTINCT ap.contextid FROM {analytics_predictions} ap
{$joinusersql}
WHERE u.id = :userid AND ap.modelid = :modelid";
$contextlist->add_from_sql($sql, ['userid' => $userid, 'modelid' => $modelid]);
// Indicator calculations.
$joinusersql = $analyser->join_sample_user('aic');
$sql = "SELECT DISTINCT aic.contextid FROM {analytics_indicator_calc} aic
{$joinusersql}
WHERE u.id = :userid AND aic.sampleorigin = :analysersamplesorigin";
$contextlist->add_from_sql($sql, ['userid' => $userid, 'analysersamplesorigin' => $analyser->get_samples_origin()]);
}
// We can leave this out of the loop as there is no analyser-dependent stuff.
list($sql, $params) = self::analytics_prediction_actions_user_sql($userid, array_keys($models));
$sql = "SELECT DISTINCT ap.contextid" . $sql;
$contextlist->add_from_sql($sql, $params);
return $contextlist;
}
/**
* Get the list of users who have data within a context.
*
* @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination.
*/
public static function get_users_in_context(userlist $userlist) {
global $DB;
$context = $userlist->get_context();
$models = self::get_models_with_user_data();
foreach ($models as $modelid => $model) {
$analyser = $model->get_analyser(['notimesplitting' => true]);
// Analytics predictions.
$params = [
'contextid' => $context->id,
'modelid' => $modelid,
];
$joinusersql = $analyser->join_sample_user('ap');
$sql = "SELECT u.id AS userid
FROM {analytics_predictions} ap
{$joinusersql}
WHERE ap.contextid = :contextid
AND ap.modelid = :modelid";
$userlist->add_from_sql('userid', $sql, $params);
// Indicator calculations.
$params = [
'contextid' => $context->id,
'analysersamplesorigin' => $analyser->get_samples_origin(),
];
$joinusersql = $analyser->join_sample_user('aic');
$sql = "SELECT u.id AS userid
FROM {analytics_indicator_calc} aic
{$joinusersql}
WHERE aic.contextid = :contextid
AND aic.sampleorigin = :analysersamplesorigin";
$userlist->add_from_sql('userid', $sql, $params);
}
// We can leave this out of the loop as there is no analyser-dependent stuff.
list($sql, $params) = self::analytics_prediction_actions_context_sql($context->id, array_keys($models));
$sql = "SELECT apa.userid" . $sql;
$userlist->add_from_sql('userid', $sql, $params);
}
/**
* Export all user data for the specified user, in the specified contexts.
*
* @param approved_contextlist $contextlist The approved contexts to export information for.
*/
public static function export_user_data(approved_contextlist $contextlist) {
global $DB;
$userid = intval($contextlist->get_user()->id);
$models = self::get_models_with_user_data();
$modelids = array_keys($models);
list ($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
$rootpath = [get_string('analytics', 'analytics')];
$ctxfields = \context_helper::get_preload_record_columns_sql('ctx');
foreach ($models as $modelid => $model) {
$analyser = $model->get_analyser(['notimesplitting' => true]);
// Analytics predictions.
$joinusersql = $analyser->join_sample_user('ap');
$sql = "SELECT ap.*, $ctxfields FROM {analytics_predictions} ap
JOIN {context} ctx ON ctx.id = ap.contextid
{$joinusersql}
WHERE u.id = :userid AND ap.modelid = :modelid AND ap.contextid {$contextsql}";
$params = ['userid' => $userid, 'modelid' => $modelid] + $contextparams;
$predictions = $DB->get_recordset_sql($sql, $params);
foreach ($predictions as $prediction) {
\context_helper::preload_from_record($prediction);
$context = \context::instance_by_id($prediction->contextid);
$path = $rootpath;
$path[] = get_string('privacy:metadata:analytics:predictions', 'analytics');
$path[] = $prediction->id;
$data = (object)[
'target' => $model->get_target()->get_name()->out(),
'context' => $context->get_context_name(true, true),
'prediction' => $model->get_target()->get_display_value($prediction->prediction),
'timestart' => transform::datetime($prediction->timestart),
'timeend' => transform::datetime($prediction->timeend),
'timecreated' => transform::datetime($prediction->timecreated),
];
writer::with_context($context)->export_data($path, $data);
}
$predictions->close();
// Indicator calculations.
$joinusersql = $analyser->join_sample_user('aic');
$sql = "SELECT aic.*, $ctxfields FROM {analytics_indicator_calc} aic
JOIN {context} ctx ON ctx.id = aic.contextid
{$joinusersql}
WHERE u.id = :userid AND aic.sampleorigin = :analysersamplesorigin AND aic.contextid {$contextsql}";
$params = ['userid' => $userid, 'analysersamplesorigin' => $analyser->get_samples_origin()] + $contextparams;
$indicatorcalculations = $DB->get_recordset_sql($sql, $params);
foreach ($indicatorcalculations as $calculation) {
\context_helper::preload_from_record($calculation);
$context = \context::instance_by_id($calculation->contextid);
$path = $rootpath;
$path[] = get_string('privacy:metadata:analytics:indicatorcalc', 'analytics');
$path[] = $calculation->id;
$indicator = \core_analytics\manager::get_indicator($calculation->indicator);
$data = (object)[
'indicator' => $indicator::get_name()->out(),
'context' => $context->get_context_name(true, true),
'calculation' => $indicator->get_display_value($calculation->value),
'starttime' => transform::datetime($calculation->starttime),
'endtime' => transform::datetime($calculation->endtime),
'timecreated' => transform::datetime($calculation->timecreated),
];
writer::with_context($context)->export_data($path, $data);
}
$indicatorcalculations->close();
}
// Analytics predictions.
// Provided contexts are ignored as we export all user-related stuff.
list($sql, $params) = self::analytics_prediction_actions_user_sql($userid, $modelids, $contextsql);
$sql = "SELECT apa.*, ap.modelid, ap.contextid, $ctxfields" . $sql;
$predictionactions = $DB->get_recordset_sql($sql, $params + $contextparams);
foreach ($predictionactions as $predictionaction) {
\context_helper::preload_from_record($predictionaction);
$context = \context::instance_by_id($predictionaction->contextid);
$path = $rootpath;
$path[] = get_string('privacy:metadata:analytics:predictionactions', 'analytics');
$path[] = $predictionaction->id;
$data = (object)[
'target' => $models[$predictionaction->modelid]->get_target()->get_name()->out(),
'context' => $context->get_context_name(true, true),
'action' => $predictionaction->actionname,
'timecreated' => transform::datetime($predictionaction->timecreated),
];
writer::with_context($context)->export_data($path, $data);
}
$predictionactions->close();
}
/**
* Delete all data for all users in the specified context.
*
* @param context $context The specific context to delete data for.
*/
public static function delete_data_for_all_users_in_context(\context $context) {
global $DB;
$models = self::get_models_with_user_data();
$modelids = array_keys($models);
foreach ($models as $modelid => $model) {
$idssql = "SELECT ap.id FROM {analytics_predictions} ap
WHERE ap.contextid = :contextid AND ap.modelid = :modelid";
$idsparams = ['contextid' => $context->id, 'modelid' => $modelid];
$DB->delete_records_select('analytics_prediction_actions', "predictionid IN ($idssql)", $idsparams);
$DB->delete_records_select('analytics_predictions', "contextid = :contextid AND modelid = :modelid", $idsparams);
}
// We delete them all this table is just a cache and we don't know which model filled it.
$DB->delete_records('analytics_indicator_calc', ['contextid' => $context->id]);
}
/**
* Delete all user data for the specified user, in the specified contexts.
*
* @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
*/
public static function delete_data_for_user(approved_contextlist $contextlist) {
global $DB;
$userid = intval($contextlist->get_user()->id);
$models = self::get_models_with_user_data();
$modelids = array_keys($models);
list ($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
// Analytics prediction actions.
list($sql, $apaparams) = self::analytics_prediction_actions_user_sql($userid, $modelids, $contextsql);
$sql = "SELECT apa.id " . $sql;
$predictionactionids = $DB->get_fieldset_sql($sql, $apaparams + $contextparams);
if ($predictionactionids) {
list ($predictionactionidssql, $params) = $DB->get_in_or_equal($predictionactionids);
$DB->delete_records_select('analytics_prediction_actions', "id {$predictionactionidssql}", $params);
}
foreach ($models as $modelid => $model) {
$analyser = $model->get_analyser(['notimesplitting' => true]);
// Analytics predictions.
$joinusersql = $analyser->join_sample_user('ap');
$sql = "SELECT DISTINCT ap.id FROM {analytics_predictions} ap
{$joinusersql}
WHERE u.id = :userid AND ap.modelid = :modelid AND ap.contextid {$contextsql}";
$predictionids = $DB->get_fieldset_sql($sql, ['userid' => $userid, 'modelid' => $modelid] + $contextparams);
if ($predictionids) {
list($predictionidssql, $params) = $DB->get_in_or_equal($predictionids, SQL_PARAMS_NAMED);
$DB->delete_records_select('analytics_predictions', "id $predictionidssql", $params);
}
// Indicator calculations.
$joinusersql = $analyser->join_sample_user('aic');
$sql = "SELECT DISTINCT aic.id FROM {analytics_indicator_calc} aic
{$joinusersql}
WHERE u.id = :userid AND aic.sampleorigin = :analysersamplesorigin AND aic.contextid {$contextsql}";
$params = ['userid' => $userid, 'analysersamplesorigin' => $analyser->get_samples_origin()] + $contextparams;
$indicatorcalcids = $DB->get_fieldset_sql($sql, $params);
if ($indicatorcalcids) {
list ($indicatorcalcidssql, $params) = $DB->get_in_or_equal($indicatorcalcids, SQL_PARAMS_NAMED);
$DB->delete_records_select('analytics_indicator_calc', "id $indicatorcalcidssql", $params);
}
}
}
/**
* Delete multiple users within a single context.
*
* @param approved_userlist $userlist The approved context and user information to delete information for.
*/
public static function delete_data_for_users(approved_userlist $userlist) {
global $DB;
$context = $userlist->get_context();
$models = self::get_models_with_user_data();
$modelids = array_keys($models);
list($usersinsql, $baseparams) = $DB->get_in_or_equal($userlist->get_userids(), SQL_PARAMS_NAMED);
// Analytics prediction actions.
list($sql, $apaparams) = self::analytics_prediction_actions_context_sql($context->id, $modelids, $usersinsql);
$sql = "SELECT apa.id" . $sql;
$predictionactionids = $DB->get_fieldset_sql($sql, $baseparams + $apaparams);
if ($predictionactionids) {
list ($predictionactionidssql, $params) = $DB->get_in_or_equal($predictionactionids);
$DB->delete_records_select('analytics_prediction_actions', "id {$predictionactionidssql}", $params);
}
$baseparams['contextid'] = $context->id;
foreach ($models as $modelid => $model) {
$analyser = $model->get_analyser(['notimesplitting' => true]);
// Analytics predictions.
$joinusersql = $analyser->join_sample_user('ap');
$sql = "SELECT DISTINCT ap.id
FROM {analytics_predictions} ap
{$joinusersql}
WHERE ap.contextid = :contextid
AND ap.modelid = :modelid
AND u.id {$usersinsql}";
$params = $baseparams;
$params['modelid'] = $modelid;
$predictionids = $DB->get_fieldset_sql($sql, $params);
if ($predictionids) {
list($predictionidssql, $params) = $DB->get_in_or_equal($predictionids, SQL_PARAMS_NAMED);
$DB->delete_records_select('analytics_predictions', "id {$predictionidssql}", $params);
}
// Indicator calculations.
$joinusersql = $analyser->join_sample_user('aic');
$sql = "SELECT DISTINCT aic.id
FROM {analytics_indicator_calc} aic
{$joinusersql}
WHERE aic.contextid = :contextid
AND aic.sampleorigin = :analysersamplesorigin
AND u.id {$usersinsql}";
$params = $baseparams;
$params['analysersamplesorigin'] = $analyser->get_samples_origin();
$indicatorcalcids = $DB->get_fieldset_sql($sql, $params);
if ($indicatorcalcids) {
list ($indicatorcalcidssql, $params) = $DB->get_in_or_equal($indicatorcalcids, SQL_PARAMS_NAMED);
$DB->delete_records_select('analytics_indicator_calc', "id $indicatorcalcidssql", $params);
}
}
}
/**
* Returns a list of models with user data.
*
* @return \core_analytics\model[]
*/
private static function get_models_with_user_data() {
$models = \core_analytics\manager::get_all_models();
foreach ($models as $modelid => $model) {
$analyser = $model->get_analyser(['notimesplitting' => true]);
if (!$analyser->processes_user_data()) {
unset($models[$modelid]);
}
}
return $models;
}
/**
* Returns the sql query to query analytics_prediction_actions table by user ID.
*
* @param int $userid The user ID of the analytics prediction.
* @param int[] $modelids Model IDs to include in the SQL.
* @param string $contextsql Optional "in or equal" SQL to also query by context ID(s).
* @return array sql string in [0] and params in [1].
*/
private static function analytics_prediction_actions_user_sql($userid, $modelids, $contextsql = false) {
global $DB;
list($insql, $params) = $DB->get_in_or_equal($modelids, SQL_PARAMS_NAMED);
$sql = " FROM {analytics_predictions} ap
JOIN {context} ctx ON ctx.id = ap.contextid
JOIN {analytics_prediction_actions} apa ON apa.predictionid = ap.id
JOIN {analytics_models} am ON ap.modelid = am.id
WHERE apa.userid = :userid AND ap.modelid {$insql}";
$params['userid'] = $userid;
if ($contextsql) {
$sql .= " AND ap.contextid $contextsql";
}
return [$sql, $params];
}
/**
* Returns the sql query to query analytics_prediction_actions table by context ID.
*
* @param int $contextid The context ID of the analytics prediction.
* @param int[] $modelids Model IDs to include in the SQL.
* @param string $usersql Optional "in or equal" SQL to also query by user ID(s).
* @return array sql string in [0] and params in [1].
*/
private static function analytics_prediction_actions_context_sql($contextid, $modelids, $usersql = false) {
global $DB;
list($insql, $params) = $DB->get_in_or_equal($modelids, SQL_PARAMS_NAMED);
$sql = " FROM {analytics_predictions} ap
JOIN {analytics_prediction_actions} apa ON apa.predictionid = ap.id
WHERE ap.contextid = :contextid
AND ap.modelid {$insql}";
$params['contextid'] = $contextid;
if ($usersql) {
$sql .= " AND apa.userid {$usersql}";
}
return [$sql, $params];
}
}
+71
View File
@@ -0,0 +1,71 @@
<?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/>.
/**
* Regressors interface.
*
* @package core_analytics
* @copyright 2017 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();
/**
* Regressors interface.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
interface regressor extends predictor {
/**
* Train this processor regression model using the provided supervised learning dataset.
*
* @param string $uniqueid
* @param \stored_file $dataset
* @param string $outputdir
* @return \stdClass
*/
public function train_regression($uniqueid, \stored_file $dataset, $outputdir);
/**
* Estimates linear values for the provided dataset samples.
*
* @param string $uniqueid
* @param \stored_file $dataset
* @param mixed $outputdir
* @return void
*/
public function estimate($uniqueid, \stored_file $dataset, $outputdir);
/**
* Evaluates this processor regression model using the provided supervised learning dataset.
*
* @param string $uniqueid
* @param float $maxdeviation
* @param int $niterations
* @param \stored_file $dataset
* @param string $outputdir
* @param string $trainedmodeldir
* @return \stdClass
*/
public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
$outputdir, $trainedmodeldir);
}
@@ -0,0 +1,38 @@
<?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/>.
/**
* Model requirements exception.
*
* @package core_analytics
* @copyright 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;
/**
* Dummy class to identify model requirements exceptions.
*
* @package core_search
* @copyright David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class requirements_exception extends \moodle_exception {
}
+109
View File
@@ -0,0 +1,109 @@
<?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/>.
/**
* Moodle site analysable.
*
* @package core_analytics
* @copyright 2016 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();
/**
* Moodle site analysable.
*
* @package core_analytics
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class site implements \core_analytics\analysable {
/**
* @var int
*/
protected $start;
/**
* @var int
*/
protected $end;
/**
* Analysable id
*
* @return int
*/
public function get_id() {
return SYSCONTEXTID;
}
/**
* Site.
*
* @return string
*/
public function get_name() {
return get_string('site');
}
/**
* Analysable context.
*
* @return \context
*/
public function get_context() {
return \context_system::instance();
}
/**
* Analysable start timestamp.
*
* @return int
*/
public function get_start() {
if (!empty($this->start)) {
return $this->start;
}
// Much faster than reading the first log in the site.
$admins = get_admins();
$this->start = 9999999999;
foreach ($admins as $admin) {
if ($admin->firstaccess < $this->start) {
$this->start = $admin->firstaccess;
}
}
return $this->start;
}
/**
* Analysable end timestamp.
*
* @return int
*/
public function get_end() {
if (!empty($this->end)) {
return $this->end;
}
$this->end = time();
return $this->end;
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
// This file is part of Moodle - https://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/>.
/**
* Provides the {@link \core_analytics\stats} class.
*
* @package core_analytics
* @copyright 2019 David Mudrák <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
/**
* Provides stats and meta information about the analytics usage on this site.
*
* @copyright 2019 David Mudrák <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class stats {
/**
* Return the number of models enabled on this site.
*
* @return int
*/
public static function enabled_models(): int {
return count(manager::get_all_models(true));
}
/**
* Return the number of predictions generated by the system.
*
* @return int
*/
public static function predictions(): int {
global $DB;
return $DB->count_records('analytics_predictions');
}
/**
* Return the number of suggested actions executed by users.
*
* @return int
*/
public static function actions(): int {
global $DB;
return $DB->count_records('analytics_prediction_actions');
}
/**
* Return the number of suggested actions flagged as not useful.
*
* @return int
*/
public static function actions_not_useful(): int {
global $DB;
// Simple version using core's TYPE_NEGATIVE actions.
return $DB->count_records_select('analytics_prediction_actions',
'actionname = :notuseful OR actionname = :incorrectlyflagged',
['notuseful' => prediction::ACTION_NOT_USEFUL, 'incorrectlyflagged' => prediction::ACTION_INCORRECTLY_FLAGGED]);
}
}
+210
View File
@@ -0,0 +1,210 @@
<?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/>.
/**
* Moodle user analysable
*
* @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();
/**
* Moodle user analysable
*
* @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 user implements \core_analytics\analysable {
/**
* @var bool Has this user data been already loaded.
*/
protected $loaded = false;
/**
* @var int $cachedid self::$cachedinstance analysable id.
*/
protected static $cachedid = 0;
/**
* @var \core_analytics\user $cachedinstance
*/
protected static $cachedinstance = null;
/**
* User object
*
* @var \stdClass
*/
protected $user = null;
/**
* The user context.
*
* @var \context_user
*/
protected $usercontext = null;
/** @var int Store current Unix timestamp. */
protected int $now = 0;
/**
* Constructor.
*
* Use self::instance() instead to get cached copies of the class. Instances obtained
* through this constructor will not be cached.
*
* @param int|\stdClass $user User id
* @param \context|null $context
* @return void
*/
public function __construct($user, ?\context $context = null) {
if (is_scalar($user)) {
$this->user = new \stdClass();
$this->user->id = $user;
} else {
$this->user = $user;
}
if (!is_null($context)) {
$this->usercontext = $context;
}
}
/**
* Returns an analytics user instance.
*
* Lazy load of analysable data.
*
* @param int|\stdClass $user User object or user id
* @param \context|null $context
* @return \core_analytics\user
*/
public static function instance($user, ?\context $context = null) {
$userid = $user;
if (!is_scalar($userid)) {
$userid = $user->id;
}
if (self::$cachedid === $userid) {
return self::$cachedinstance;
}
$cachedinstance = new \core_analytics\user($user, $context);
self::$cachedinstance = $cachedinstance;
self::$cachedid = (int)$userid;
return self::$cachedinstance;
}
/**
* get_id
*
* @return int
*/
public function get_id() {
return $this->user->id;
}
/**
* Loads the analytics user object.
*
* @return void
*/
protected function load() {
// The instance constructor could be already loaded with the full user object. Using email
// because it is a required user field.
if (empty($this->user->email)) {
$this->user = \core_user::get_user($this->user->id);
}
$this->usercontext = $this->get_context();
$this->now = time();
// Flag the instance as loaded.
$this->loaded = true;
}
/**
* The user full name.
*
* @return string
*/
public function get_name() {
if (!$this->loaded) {
$this->load();
}
return fullname($this->user);
}
/**
* get_context
*
* @return \context
*/
public function get_context() {
if ($this->usercontext === null) {
$this->usercontext = \context_user::instance($this->user->id);
}
return $this->usercontext;
}
/**
* Get the start timestamp.
*
* @return int
*/
public function get_start() {
if (!$this->loaded) {
$this->load();
}
return $this->user->timecreated;
}
/**
* Get the end timestamp.
*
* @return int
*/
public function get_end() {
return self::MAX_TIME;
}
/**
* Returns a user plain object.
*
* @return \stdClass
*/
public function get_user_data() {
if (!$this->loaded) {
$this->load();
}
return $this->user;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
// This file is part of Moodle - https://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/>.
/**
* The interface library between the core and the subsystem.
*
* @package core_analytics
* @copyright 2019 David Mudrák <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use core_external\external_api;
/**
* Implements the inplace editable feature.
*
* @param string $itemtype Type if the inplace editable element
* @param int $itemid Identifier of the element
* @param string $newvalue New value for the element
* @return \core\output\inplace_editable
*/
function core_analytics_inplace_editable($itemtype, $itemid, $newvalue) {
if ($itemtype === 'modelname') {
external_api::validate_context(context_system::instance());
require_capability('moodle/analytics:managemodels', \context_system::instance());
$model = new \core_analytics\model($itemid);
$model->rename(clean_param($newvalue, PARAM_NOTAGS));
return $model->inplace_editable_name();
}
}
@@ -0,0 +1,39 @@
{{!
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/insight_info_message
HTML message for insights
Classes required for JS:
* none
Data attributes required for JS:
* none
Example context (json):
{
"url": "https://moodle.org",
"insightinfomessage": "This insight is very <strong>useful</strong> because bla bla bla."
}
}}
{{> core_analytics/notification_styles}}
{{{insightinfomessage}}}
<br/><br/>
<a class="btn btn-outline-primary btn-insight" href="{{url}}">{{#str}} viewinsight, analytics {{/str}}</a>
@@ -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/insight_info_message_prediction
HTML message for insights with one single prediction
Classes required for JS:
* none
Data attributes required for JS:
* none
Example context (json):
{
"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"
}
}
}}
{{> core_analytics/notification_styles}}
{{#body}}
{{{.}}}
{{/body}}
<br/>
{{#actions}}
<a class="btn btn-outline-primary mr-3 mb-3 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}}
@@ -0,0 +1,60 @@
{{!
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):
{
}
}}
{{! The styles defined here will be included in the Moodle web UI and in emails. Emails do not include Moodle
stylesheets so we want these styles to be applied to emails. However, they will also be included in the Moodle web UI.
We use the not(.dir-ltr):not(.dir-rtl) so that this style is not applied to the Moodle UI.
Note that gmail strips out HTML styles which selector includes the caracters (), so the font-family rule
is not applied in gmail.}}
<head><style>
body:not(.dir-ltr):not(.dir-rtl) {
font-family: 'Open Sans', sans-serif;
}
.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;
line-height: 1.5;
border-radius: 0;
text-decoration: none;
cursor: pointer;
}
</style></head>
+88
View File
@@ -0,0 +1,88 @@
<?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/>.
namespace core_analytics;
/**
* Unit tests for the analysis class.
*
* @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
*/
class analysis_test extends \advanced_testcase {
/**
* Test fill_firstanalyses_cache.
* @return null
*/
public function test_fill_firstanalyses_cache(): void {
require_once(__DIR__ . '/fixtures/test_timesplitting_upcoming_seconds.php');
$this->resetAfterTest();
$modelid = 1;
$params = ['startdate' => (new \DateTimeImmutable('-5 seconds'))->getTimestamp()];
$course1 = $this->getDataGenerator()->create_course($params);
$course2 = $this->getDataGenerator()->create_course($params);
$analysable1 = new \core_analytics\course($course1);
$afewsecsago = time() - 5;
$earliest = $afewsecsago - 1;
$this->insert_used($modelid, $course1->id, 'training', $afewsecsago);
// Course2 processed after course1.
$this->insert_used($modelid, $course2->id, 'training', $afewsecsago + 1);
// After the first process involving course1.
$this->insert_used($modelid, $course1->id, 'prediction', $afewsecsago + 5);
$firstanalyses = \core_analytics\analysis::fill_firstanalyses_cache($modelid);
$this->assertCount(2, $firstanalyses);
$this->assertEquals($afewsecsago, $firstanalyses[$modelid . '_' . $course1->id]);
$this->assertEquals($afewsecsago + 1, $firstanalyses[$modelid . '_' . $course2->id]);
// The cached elements get refreshed.
$this->insert_used($modelid, $course1->id, 'prediction', $earliest);
$firstanalyses = \core_analytics\analysis::fill_firstanalyses_cache($modelid, $course1->id);
$this->assertCount(1, $firstanalyses);
$this->assertEquals($earliest, $firstanalyses[$modelid . '_' . $course1->id]);
// Upcoming periodic time-splitting methods can read and process the cached data.
$seconds = new \test_timesplitting_upcoming_seconds();
$seconds->set_modelid($modelid);
$seconds->set_analysable($analysable1);
// The generated ranges should start from the cached firstanalysis value, which is $earliest.
$ranges = $seconds->get_all_ranges();
$this->assertGreaterThanOrEqual(7, count($ranges));
$firstrange = reset($ranges);
$this->assertEquals($earliest, $firstrange['time']);
}
private function insert_used($modelid, $analysableid, $action, $timestamp) {
global $DB;
$obj = new \stdClass();
$obj->modelid = $modelid;
$obj->action = $action;
$obj->analysableid = $analysableid;
$obj->firstanalysis = $timestamp;
$obj->timeanalysed = $timestamp;
$obj->id = $DB->insert_record('analytics_used_analysables', $obj);
}
}
+163
View File
@@ -0,0 +1,163 @@
@core @core_analytics @javascript
Feature: Manage analytics models
In order to manage analytics models
As a manager
I need to create and use a model
Background:
# Turn off the course welcome message, so we can easily test other messages.
Given the following config values are set as admin:
| onlycli | 0 | analytics |
| sendcoursewelcomemessage | 0 | enrol_manual |
And the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
| manager1 | Manager | 1 | manager1@example.com |
| student0 | Student | 0 | student0@example.com |
| student1 | Student | 1 | student1@example.com |
| student2 | Student | 2 | student2@example.com |
| student3 | Student | 3 | student3@example.com |
| student4 | Student | 4 | student4@example.com |
| student5 | Student | 5 | student5@example.com |
| student6 | Student | 6 | student6@example.com |
And the following "system role assigns" exist:
| user | course | role |
| manager1 | Acceptance test site | manager |
And the following "courses" exist:
| fullname | shortname | category | enddate | startdate | enablecompletion |
| Course 1 | C1 | 0 | ## yesterday ## | ## 2 days ago ## | 1 |
| Course 2 | C2 | 0 | ## yesterday ## | ## 2 days ago ## | 1 |
| Course 3 | C3 | 0 | ## tomorrow ## | ## 2 days ago ## | 1 |
And the following "course enrolments" exist:
| user | course | role | timeend | timestart |
| teacher1 | C1 | editingteacher | ## 1 day ago ## | ## 2 days ago ## |
| student0 | C1 | student | ## 1 day ago ## | ## 2 days ago ## |
| student1 | C1 | student | ## 1 day ago ## | ## 2 days ago ## |
| student2 | C1 | student | ## 1 day ago ## | ## 2 days ago ## |
| teacher1 | C2 | editingteacher | ## 1 day ago ## | ## 2 days ago ## |
| student3 | C2 | student | ## 1 day ago ## | ## 2 days ago ## |
| student4 | C2 | student | ## 1 day ago ## | ## 2 days ago ## |
| teacher1 | C3 | editingteacher | 0 | ## 2 days ago ## |
| manager1 | C3 | manager | 0 | ## 2 days ago ## |
| student5 | C3 | student | 0 | ## 2 days ago ## |
| student6 | C3 | student | 0 | ## 2 days ago ## |
And the following "activities" exist:
| activity | name | intro | course | idnumber | section | completion | completionview |
| assign | assign1 | A1 desc | C1 | assign1 | 0 | 2 | 1 |
| assign | assign2 | A2 desc | C2 | assign2 | 0 | 2 | 1 |
| assign | assign3 | A3 desc | C3 | assign3 | 0 | 2 | 1 |
And the following "analytics model" exist:
| target | indicators | timesplitting | enabled |
| \core_course\analytics\target\course_completion | \core\analytics\indicator\any_write_action,\core\analytics\indicator\read_actions | \core\analytics\time_splitting\single_range | true |
And I log in as "manager1"
And I navigate to "Analytics > Analytics models" in site administration
Scenario: Create a model
When I open the action menu in ".top-nav" "css_element"
And I choose "Create model" in the open action menu
And I set the field "Enabled" to "Enable"
And I select "__core_course__analytics__target__course_completion" from the "target" singleselect
And I set the field "Indicators" to "Read actions amount, Any write action in the course"
And I select "__core__analytics__time_splitting__single_range" from the "timesplitting" singleselect
And I press "Save changes"
Then I should see "No predictions available yet" in the "Students at risk of not meeting the course completion conditions" "table_row"
Scenario: Evaluate a model
Given I am on "Course 1" course homepage
And I navigate to "Course completion" in current page administration
And I expand all fieldsets
And I set the following fields to these values:
| Assignment - assign1 | 1 |
And I click on "Save changes" "button"
And I am on "Course 2" course homepage
And I navigate to "Course completion" in current page administration
And I expand all fieldsets
And I set the following fields to these values:
| Assignment - assign2 | 1 |
And I click on "Save changes" "button"
And I am on "Course 3" course homepage
And I navigate to "Course completion" in current page administration
And I expand all fieldsets
And I set the following fields to these values:
| Assignment - assign3 | 1 |
And I click on "Save changes" "button"
And I am on site homepage
And I navigate to "Analytics > Analytics models" in site administration
And I open the action menu in "Students at risk of not meeting the course completion conditions" "table_row"
And I choose "Evaluate" in the open action menu
And I press "Evaluate"
And I should see "Evaluate model"
And I press "Continue"
# Evaluation log
And I open the action menu in "Students at risk of not meeting the course completion conditions" "table_row"
And I choose "Evaluation log" in the open action menu
And I should see "Configuration"
And I click on "View" "link"
And I should see "Log extra info"
And I click on "Close" "button" in the "Log extra info" "dialogue"
And I navigate to "Analytics > Analytics models" in site administration
# Execute scheduled analysis
And I open the action menu in "Students at risk of not meeting the course completion conditions" "table_row"
And I choose "Execute scheduled analysis" in the open action menu
And I should see "Training results"
And I press "Continue"
# Check notifications
Then I should see "1" in the "#nav-notification-popover-container [data-region='count-container']" "css_element"
And I open the notification popover
And I click on "View full notification" "link" in the ".popover-region-notifications" "css_element"
And I should see "Students at risk in Course 3 course"
When I am on site homepage
And I navigate to "Analytics > Analytics models" in site administration
# View predictions
When I select "C3" from the "contextid" singleselect
And I click on "View prediction details" "icon" in the "Student 6" "table_row"
And I should see "Prediction details"
And I should see "Any write action"
And I should see "Read actions amount"
And I click on "Select Student 6 for bulk action" "checkbox" in the "Student 6" "table_row"
And I click on "Accept" "button"
And I wait until "Confirm" "button" exists
And I click on "Confirm" "button" in the "Accept" "dialogue"
And I click on "View prediction details" "icon" in the "Student 5" "table_row"
And I click on "Select Student 5 for bulk action" "checkbox" in the "Student 5" "table_row"
And I click on "Not applicable" "button"
And I click on "Confirm" "button" in the "Not applicable" "dialogue"
And I should see "No insights reported"
# Clear predictions
When I am on site homepage
And I navigate to "Analytics > Analytics models" in site administration
And I should see "No insights reported" in the "Students at risk of not meeting the course completion conditions" "table_row"
And I open the action menu in "Students at risk of not meeting the course completion conditions" "table_row"
And I choose "Clear predictions" in the open action menu
And I press "Clear predictions"
Then I should see "No predictions available yet" in the "Students at risk of not meeting the course completion conditions" "table_row"
Scenario: Edit a model
When I open the action menu in "Students at risk of not meeting the course completion conditions" "table_row"
And I choose "Edit" in the open action menu
And I click on "Read actions amount" "text" in the ".form-autocomplete-selection" "css_element"
And I press "Save changes"
And I should not see "Read actions amount"
Scenario: Disable a model
When I open the action menu in "Students at risk of not meeting the course completion conditions" "table_row"
And I choose "Disable" in the open action menu
Then I should see "Disabled model" in the "Students at risk of not meeting the course completion conditions" "table_row"
Scenario: Export model
When I open the action menu in "Students at risk of not meeting the course completion conditions" "table_row"
And I choose "Export" in the open action menu
And I click on "Actions" "link" in the "Students at risk of not meeting the course completion conditions" "table_row"
And following "Export" should download a file that:
| Contains file in zip | model-config.json |
Scenario: Check invalid site elements
When I open the action menu in "Students at risk of not meeting the course completion conditions" "table_row"
And I choose "Invalid site elements" in the open action menu
Then I should see "Invalid analysable elements"
Scenario: Delete model
When I open the action menu in "Students at risk of not meeting the course completion conditions" "table_row"
And I choose "Delete" in the open action menu
And I click on "Delete" "button" in the "Delete" "dialogue"
Then I should not see "Students at risk of not meeting the course completion conditions"
+96
View File
@@ -0,0 +1,96 @@
<?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/>.
namespace core_analytics;
/**
* 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 calculation_info_test 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): void {
require_once(__DIR__ . '/fixtures/test_indicator_max.php');
require_once(__DIR__ . '/fixtures/test_indicator_min.php');
$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']],
];
}
}
@@ -0,0 +1,346 @@
<?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/>.
namespace core_analytics;
use advanced_testcase;
use ReflectionClass;
use ReflectionMethod;
use stdClass;
/**
* Unit tests for activities completed by classification.
*
* @package core_analytics
* @covers \core_analytics\local\indicator\community_of_inquiry_activity
* @copyright 2017 David Monllaó {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
final class community_of_inquiry_activities_completed_by_test extends advanced_testcase {
/**
* availability_levels
*
* @return array
*/
public static function availability_levels(): array {
return array(
'activity' => array('activity'),
'section' => array('section'),
);
}
/**
* test_get_activities_with_availability
*
* @dataProvider availability_levels
* @param string $availabilitylevel
* @return void
*/
public function test_get_activities_with_availability($availabilitylevel) {
list($course, $stu1) = $this->setup_course();
// Forum1 is ignored as section 0 does not count.
$forum = $this->getDataGenerator()->create_module('forum', array('course' => $course->id));
$modinfo = get_fast_modinfo($course, $stu1->id);
$cm = $modinfo->get_cm($forum->cmid);
if ($availabilitylevel === 'activity') {
$availabilityinfo = new \core_availability\info_module($cm);
} else if ($availabilitylevel === 'section') {
$availabilityinfo = new \core_availability\info_section($cm->get_modinfo()->get_section_info($cm->sectionnum));
} else {
$this->fail('Unsupported availability level');
}
$fromtime = strtotime('2015-10-22 00:00:00 GMT');
$untiltime = strtotime('2015-10-24 00:00:00 GMT');
$structure = (object)array('op' => '|', 'show' => true, 'c' => array(
(object)array('type' => 'date', 'd' => '<', 't' => $untiltime),
(object)array('type' => 'date', 'd' => '>=', 't' => $fromtime)
));
$method = new ReflectionMethod($availabilityinfo, 'set_in_database');
$method->invoke($availabilityinfo, json_encode($structure));
$this->setUser($stu1);
// Reset modinfo we also want coursemodinfo cache definition to be cleared.
get_fast_modinfo($course, $stu1->id, true);
rebuild_course_cache($course->id, true);
$modinfo = get_fast_modinfo($course, $stu1->id);
$cm = $modinfo->get_cm($forum->cmid);
$course = new \core_analytics\course($course);
list($indicator, $method) = $this->instantiate_indicator('mod_forum', $course);
// Condition from after provided end time.
$this->assertCount(0, $method->invoke($indicator, strtotime('2015-10-20 00:00:00 GMT'),
strtotime('2015-10-21 00:00:00 GMT'), $stu1));
// Condition until before provided start time
$this->assertCount(0, $method->invoke($indicator, strtotime('2015-10-25 00:00:00 GMT'),
strtotime('2015-10-26 00:00:00 GMT'), $stu1));
// Condition until after provided end time.
$this->assertCount(0, $method->invoke($indicator, strtotime('2015-10-22 00:00:00 GMT'),
strtotime('2015-10-23 00:00:00 GMT'), $stu1));
// Condition until after provided start time and before provided end time.
$this->assertCount(1, $method->invoke($indicator, strtotime('2015-10-22 00:00:00 GMT'),
strtotime('2015-10-25 00:00:00 GMT'), $stu1));
}
/**
* test_get_activities_with_weeks
*
* @return void
*/
public function test_get_activities_with_weeks() {
$startdate = gmmktime('0', '0', '0', 10, 24, 2015);
$record = array(
'format' => 'weeks',
'numsections' => 4,
'startdate' => $startdate,
);
list($course, $stu1) = $this->setup_course($record);
// Forum1 is ignored as section 0 does not count.
$forum1 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id),
array('section' => 0));
$forum2 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id),
array('section' => 1));
$forum3 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id),
array('section' => 2));
$forum4 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id),
array('section' => 4));
$forum5 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id),
array('section' => 4));
$course = new \core_analytics\course($course);
list($indicator, $method) = $this->instantiate_indicator('mod_forum', $course);
$this->setUser($stu1);
$first = $startdate;
$second = $startdate + WEEKSECS;
$third = $startdate + (WEEKSECS * 2);
$forth = $startdate + (WEEKSECS * 3);
$this->assertCount(2, $method->invoke($indicator, $first, $first + WEEKSECS, $stu1));
$this->assertCount(1, $method->invoke($indicator, $second, $second + WEEKSECS, $stu1));
$this->assertCount(0, $method->invoke($indicator, $third, $third + WEEKSECS, $stu1));
$this->assertCount(2, $method->invoke($indicator, $forth, $forth + WEEKSECS, $stu1));
}
/**
* test_get_activities_by_section
*
* @return void
*/
public function test_get_activities_by_section() {
// This makes debugging easier, sorry WA's +8 :).
$this->setTimezone('UTC');
// 1 year.
$startdate = gmmktime('0', '0', '0', 10, 24, 2015);
$enddate = gmmktime('0', '0', '0', 10, 24, 2016);
$numsections = 12;
$record = array(
'format' => 'topics',
'numsections' => $numsections,
'startdate' => $startdate,
'enddate' => $enddate
);
list($course, $stu1) = $this->setup_course($record);
// Forum1 is ignored as section 0 does not count.
$forum1 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id),
array('section' => 0));
$forum2 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id),
array('section' => 1));
$forum3 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id),
array('section' => 4));
$forum4 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id),
array('section' => 8));
$forum5 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id),
array('section' => 10));
$forum6 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id),
array('section' => 12));
$course = new \core_analytics\course($course);
list($indicator, $method) = $this->instantiate_indicator('mod_forum', $course);
$this->setUser($stu1);
// Split the course in quarters.
$duration = ($enddate - $startdate) / 4;
$first = $startdate;
$second = $startdate + $duration;
$third = $startdate + ($duration * 2);
$forth = $startdate + ($duration * 3);
$this->assertCount(1, $method->invoke($indicator, $first, $first + $duration, $stu1));
$this->assertCount(1, $method->invoke($indicator, $second, $second + $duration, $stu1));
$this->assertCount(1, $method->invoke($indicator, $third, $third + $duration, $stu1));
$this->assertCount(2, $method->invoke($indicator, $forth, $forth + $duration, $stu1));
// Split the course in as many parts as sections.
$duration = ($enddate - $startdate) / $numsections;
for ($i = 1; $i <= $numsections; $i++) {
// The -1 because section 1 start represents the course start.
$timeranges[$i] = $startdate + ($duration * ($i - 1));
}
$this->assertCount(1, $method->invoke($indicator, $timeranges[1], $timeranges[1] + $duration, $stu1));
$this->assertCount(1, $method->invoke($indicator, $timeranges[4], $timeranges[4] + $duration, $stu1));
$this->assertCount(1, $method->invoke($indicator, $timeranges[8], $timeranges[8] + $duration, $stu1));
$this->assertCount(1, $method->invoke($indicator, $timeranges[10], $timeranges[10] + $duration, $stu1));
$this->assertCount(1, $method->invoke($indicator, $timeranges[12], $timeranges[12] + $duration, $stu1));
// Nothing here.
$this->assertCount(0, $method->invoke($indicator, $timeranges[2], $timeranges[2] + $duration, $stu1));
$this->assertCount(0, $method->invoke($indicator, $timeranges[3], $timeranges[3] + $duration, $stu1));
}
/**
* test_get_activities_with_specific_restrictions
*
* @return void
*/
public function test_get_activities_with_specific_restrictions() {
list($course, $stu1) = $this->setup_course();
$end = strtotime('2015-10-24 00:00:00 GMT');
// 1 with time close, one without.
$params = array('course' => $course->id);
$assign1 = $this->getDataGenerator()->create_module('assign', $params);
$params['duedate'] = $end;
$assign2 = $this->getDataGenerator()->create_module('assign', $params);
// 1 with time close, one without.
$params = array('course' => $course->id);
$choice1 = $this->getDataGenerator()->create_module('choice', $params);
$params['timeclose'] = $end;
$choice1 = $this->getDataGenerator()->create_module('choice', $params);
// 1 with time close, one without.
$params = array('course' => $course->id);
$data1 = $this->getDataGenerator()->create_module('data', $params);
$params['timeavailableto'] = $end;
$data1 = $this->getDataGenerator()->create_module('data', $params);
// 1 with time close, one without.
$params = array('course' => $course->id);
$feedback1 = $this->getDataGenerator()->create_module('feedback', $params);
$params['timeclose'] = $end;
$feedback1 = $this->getDataGenerator()->create_module('feedback', $params);
// 1 with time close, one without.
$params = array('course' => $course->id);
$lesson1 = $this->getDataGenerator()->create_module('lesson', $params);
$params['deadline'] = $end;
$lesson1 = $this->getDataGenerator()->create_module('lesson', $params);
// 1 with time close, one without.
$params = array('course' => $course->id);
$quiz1 = $this->getDataGenerator()->create_module('quiz', $params);
$params['timeclose'] = $end;
$quiz1 = $this->getDataGenerator()->create_module('quiz', $params);
// 1 with time close, one without.
$params = array('course' => $course->id);
$scorm1 = $this->getDataGenerator()->create_module('scorm', $params);
$params['timeclose'] = $end;
$scorm1 = $this->getDataGenerator()->create_module('scorm', $params);
// 1 with time close, one without.
$params = array('course' => $course->id);
$workshop1 = $this->getDataGenerator()->create_module('workshop', $params);
$params['submissionend'] = $end;
$workshop1 = $this->getDataGenerator()->create_module('workshop', $params);
$course = new \core_analytics\course($course);
$activitytypes = array('mod_assign', 'mod_choice', 'mod_data', 'mod_feedback', 'mod_lesson',
'mod_quiz', 'mod_scorm', 'mod_workshop');
foreach ($activitytypes as $activitytype) {
list($indicator, $method) = $this->instantiate_indicator($activitytype, $course);
$message = $activitytype . ' activity type returned activities do not match expected size';
$this->assertCount(0, $method->invoke($indicator, strtotime('2015-10-20 00:00:00 GMT'),
strtotime('2015-10-21 00:00:00 GMT'), $stu1), $message);
$this->assertCount(0, $method->invoke($indicator, strtotime('2015-10-25 00:00:00 GMT'),
strtotime('2015-10-26 00:00:00 GMT'), $stu1), $message);
$this->assertCount(1, $method->invoke($indicator, strtotime('2015-10-22 00:00:00 GMT'),
strtotime('2015-10-25 00:00:00 GMT'), $stu1), $message);
}
}
/**
* setup_course
*
* @param stdClass $courserecord
* @return array
*/
protected function setup_course($courserecord = null) {
global $CFG;
$this->resetAfterTest(true);
$this->setAdminUser();
$CFG->enableavailability = true;
$course = $this->getDataGenerator()->create_course($courserecord);
$stu1 = $this->getDataGenerator()->create_user();
$this->getDataGenerator()->enrol_user($stu1->id, $course->id, 'student');
return array($course, $stu1);
}
/**
* Returns the module cognitive depth indicator and the reflection method.
*
* @param string $modulename
* @param \core_analytics\course $course
* @return array
*/
private function instantiate_indicator($modulename, \core_analytics\course $course) {
$classname = '\\' . $modulename . '\analytics\indicator\cognitive_depth';
$indicator = new $classname();
$class = new ReflectionClass($indicator);
$property = $class->getProperty('course');
$property->setValue($indicator, $course);
$method = new ReflectionMethod($indicator, 'get_activities');
return array($indicator, $method);
}
}
+168
View File
@@ -0,0 +1,168 @@
<?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/>.
namespace core_analytics;
/**
* Unit tests for course.
*
* @package core_analytics
* @copyright 2016 David Monllaó {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class course_test extends \advanced_testcase {
/** @var \stdClass Course record. */
protected $course;
/** @var \stdClass Student 1 user record. */
protected $stu1;
/** @var \stdClass Student 2 user record. */
protected $stu2;
/** @var \stdClass Student both user record. */
protected $both;
/** @var \stdClass Editing teacher user record. */
protected $editingteacher;
/** @var \stdClass Teacher user record. */
protected $teacher;
/** @var int Student role ID record. */
protected $studentroleid;
/** @var int Editing teacher role ID record. */
protected $editingteacherroleid;
/** @var int Teacher role ID record. */
protected $teacherroleid;
public function setUp(): void {
global $DB;
$this->course = $this->getDataGenerator()->create_course(['startdate' => 0]);
$this->stu1 = $this->getDataGenerator()->create_user();
$this->stu2 = $this->getDataGenerator()->create_user();
$this->both = $this->getDataGenerator()->create_user();
$this->editingteacher = $this->getDataGenerator()->create_user();
$this->teacher = $this->getDataGenerator()->create_user();
$this->studentroleid = $DB->get_field('role', 'id', array('shortname' => 'student'));
$this->editingteacherroleid = $DB->get_field('role', 'id', array('shortname' => 'editingteacher'));
$this->teacherroleid = $DB->get_field('role', 'id', array('shortname' => 'teacher'));
$this->getDataGenerator()->enrol_user($this->stu1->id, $this->course->id, $this->studentroleid);
$this->getDataGenerator()->enrol_user($this->stu2->id, $this->course->id, $this->studentroleid);
$this->getDataGenerator()->enrol_user($this->both->id, $this->course->id, $this->studentroleid);
$this->getDataGenerator()->enrol_user($this->both->id, $this->course->id, $this->editingteacherroleid);
$this->getDataGenerator()->enrol_user($this->editingteacher->id, $this->course->id, $this->editingteacherroleid);
$this->getDataGenerator()->enrol_user($this->teacher->id, $this->course->id, $this->teacherroleid);
}
/**
* Users tests.
*/
public function test_users(): void {
global $DB;
$this->resetAfterTest(true);
$courseman = new \core_analytics\course($this->course->id);
$this->assertCount(3, $courseman->get_user_ids(array($this->studentroleid)));
$this->assertCount(2, $courseman->get_user_ids(array($this->editingteacherroleid)));
$this->assertCount(1, $courseman->get_user_ids(array($this->teacherroleid)));
// Distinct is applied.
$this->assertCount(3, $courseman->get_user_ids(array($this->editingteacherroleid, $this->teacherroleid)));
$this->assertCount(4, $courseman->get_user_ids(array($this->editingteacherroleid, $this->studentroleid)));
}
/**
* Course validation tests.
*
* @return void
*/
public function test_course_validation(): void {
global $DB;
$this->resetAfterTest(true);
$courseman = new \core_analytics\course($this->course->id);
$this->assertFalse($courseman->was_started());
$this->assertFalse($courseman->is_finished());
// Nothing should change when assigning as teacher.
for ($i = 0; $i < 10; $i++) {
$user = $this->getDataGenerator()->create_user();
$this->getDataGenerator()->enrol_user($user->id, $this->course->id, $this->teacherroleid);
}
$courseman = new \core_analytics\course($this->course->id);
$this->assertFalse($courseman->was_started());
$this->assertFalse($courseman->is_finished());
// More students now.
for ($i = 0; $i < 10; $i++) {
$user = $this->getDataGenerator()->create_user();
$this->getDataGenerator()->enrol_user($user->id, $this->course->id, $this->studentroleid);
}
$courseman = new \core_analytics\course($this->course->id);
$this->assertFalse($courseman->was_started());
$this->assertFalse($courseman->is_finished());
// Valid start date unknown end date.
$this->course->startdate = gmmktime('0', '0', '0', 10, 24, 2015);
$DB->update_record('course', $this->course);
$courseman = new \core_analytics\course($this->course->id);
$this->assertTrue($courseman->was_started());
$this->assertFalse($courseman->is_finished());
// Valid start and end date.
$this->course->enddate = gmmktime('0', '0', '0', 8, 27, 2016);
$DB->update_record('course', $this->course);
$courseman = new \core_analytics\course($this->course->id);
$this->assertTrue($courseman->was_started());
$this->assertTrue($courseman->is_finished());
// Valid start and ongoing course.
$this->course->enddate = gmmktime('0', '0', '0', 8, 27, 2286);
$DB->update_record('course', $this->course);
$courseman = new \core_analytics\course($this->course->id);
$this->assertTrue($courseman->was_started());
$this->assertFalse($courseman->is_finished());
}
/**
* Get the minimum time that is considered valid according to guess_end logic.
*
* @param int $time
* @return int
*/
protected function time_greater_than($time) {
return $time - (WEEKSECS * 2);
}
/**
* Get the maximum time that is considered valid according to guess_end logic.
*
* @param int $time
* @return int
*/
protected function time_less_than($time) {
return $time + (WEEKSECS * 2);
}
}
+155
View File
@@ -0,0 +1,155 @@
<?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/>.
namespace core_analytics;
/**
* Unit tests for the dataset manager.
*
* @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 dataset_manager_test extends \advanced_testcase {
/** @var array Store dataset top rows. */
protected array $sharedtoprows = [];
/**
* setUp
*
* @return null
*/
public function setUp(): void {
$this->resetAfterTest(true);
$this->sharedtoprows = array(
array('var1', 'var2'),
array('value1', 'value2'),
array('header1', 'header2')
);
}
/**
* test_create_dataset
*
* @return null
*/
public function test_create_dataset(): void {
$dataset1 = new \core_analytics\dataset_manager(1, 1, 'whatever', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
$dataset1data = array_merge($this->sharedtoprows, array(array('yeah', 'yeah', 'yeah')));
$f1 = $dataset1->store($dataset1data);
$f1contents = $f1->get_content();
$this->assertStringContainsString('yeah', $f1contents);
$this->assertStringContainsString('var1', $f1contents);
$this->assertStringContainsString('value1', $f1contents);
$this->assertStringContainsString('header1', $f1contents);
}
/**
* test_merge_datasets
*
* @return null
*/
public function test_merge_datasets(): void {
$dataset1 = new \core_analytics\dataset_manager(1, 1, 'whatever', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
$dataset1data = array_merge($this->sharedtoprows, array(array('yeah', 'yeah', 'yeah')));
$f1 = $dataset1->store($dataset1data);
$dataset2 = new \core_analytics\dataset_manager(1, 2, 'whatever', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
$dataset2data = array_merge($this->sharedtoprows, array(array('no', 'no', 'no')));
$f2 = $dataset2->store($dataset2data);
$files = array($f1, $f2);
$merged = \core_analytics\dataset_manager::merge_datasets($files, 1, 'whatever',
\core_analytics\dataset_manager::LABELLED_FILEAREA);
$mergedfilecontents = $merged->get_content();
$this->assertStringContainsString('yeah', $mergedfilecontents);
$this->assertStringContainsString('no', $mergedfilecontents);
$this->assertStringContainsString('var1', $mergedfilecontents);
$this->assertStringContainsString('value1', $mergedfilecontents);
$this->assertStringContainsString('header1', $mergedfilecontents);
}
/**
* test_get_pending_files
*
* @return null
*/
public function test_get_pending_files(): void {
global $DB;
$this->resetAfterTest();
$fakemodelid = 123;
$timesplittingids = array(
'\core\analytics\time_splitting\quarters',
'\core\analytics\time_splitting\quarters_accum',
);
// No files.
$this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, true, $timesplittingids));
$this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids));
// We will reuse this analysable file to create training and prediction datasets (analysable level files are
// merged into training and prediction files).
$analysabledataset = new \core_analytics\dataset_manager($fakemodelid, 1, 'whatever',
\core_analytics\dataset_manager::LABELLED_FILEAREA, false);
$analysabledatasetdata = array_merge($this->sharedtoprows, array(array('yeah', 'yeah', 'yeah')));
$file = $analysabledataset->store($analysabledatasetdata);
// Evaluation files ignored.
$evaluationdataset = \core_analytics\dataset_manager::merge_datasets(array($file), $fakemodelid,
'\core\analytics\time_splitting\quarters', \core_analytics\dataset_manager::LABELLED_FILEAREA, true);
$this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, true, $timesplittingids));
$this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids));
// Training and prediction files are not mixed up.
$trainingfile1 = \core_analytics\dataset_manager::merge_datasets(array($file), $fakemodelid,
'\core\analytics\time_splitting\quarters', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
$this->waitForSecond();
$trainingfile2 = \core_analytics\dataset_manager::merge_datasets(array($file), $fakemodelid,
'\core\analytics\time_splitting\quarters', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
$bytimesplitting = \core_analytics\dataset_manager::get_pending_files($fakemodelid, true, $timesplittingids);
$this->assertFalse(isset($bytimesplitting['\core\analytics\time_splitting\quarters_accum']));
$this->assertCount(2, $bytimesplitting['\core\analytics\time_splitting\quarters']);
$this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids));
$predictionfile = \core_analytics\dataset_manager::merge_datasets(array($file), $fakemodelid,
'\core\analytics\time_splitting\quarters', \core_analytics\dataset_manager::UNLABELLED_FILEAREA, false);
$bytimesplitting = \core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids);
$this->assertFalse(isset($bytimesplitting['\core\analytics\time_splitting\quarters_accum']));
$this->assertCount(1, $bytimesplitting['\core\analytics\time_splitting\quarters']);
// Already used for training and prediction are discarded.
$usedfile = (object)['modelid' => $fakemodelid, 'fileid' => $trainingfile1->get_id(), 'action' => 'trained',
'time' => time()];
$DB->insert_record('analytics_used_files', $usedfile);
$bytimesplitting = \core_analytics\dataset_manager::get_pending_files($fakemodelid, true, $timesplittingids);
$this->assertCount(1, $bytimesplitting['\core\analytics\time_splitting\quarters']);
$usedfile->fileid = $predictionfile->get_id();
$usedfile->action = 'predicted';
$DB->insert_record('analytics_used_files', $usedfile);
$this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids));
}
}
@@ -0,0 +1,37 @@
<?php
// This file is part of Moodle - https://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/>.
/**
* Example of an invalid db/analytics.php file content used for unit tests.
*
* @package core_analytics
* @category test
* @copyright 2019 David Mudrák <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$models = [
[
'target' => 'test_target_course_level_shortname',
'indicators' => [
'\core_course\analytics\indicator\no_teacher',
],
// Cannot be enabled without timesplitting defined.
'enabled' => true,
],
];
@@ -0,0 +1,36 @@
<?php
// This file is part of Moodle - https://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/>.
/**
* Example of an invalid db/analytics.php file content used for unit tests.
*
* @package core_analytics
* @category test
* @copyright 2019 David Mudrák <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$models = [
[
'target' => 'test_target_course_level_shortname',
'indicators' => [
'\core_course\analytics\indicator\no_teacher',
'\non\existing\class\name',
],
],
];
@@ -0,0 +1,36 @@
<?php
// This file is part of Moodle - https://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/>.
/**
* Example of an invalid db/analytics.php file content used for unit tests.
*
* @package core_analytics
* @category test
* @copyright 2019 David Mudrák <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$models = [
[
'target' => 'there_should_be_a_valid_fully_qualified_classname_here',
'indicators' => [
'\core_course\analytics\indicator\no_teacher',
'\core_course\analytics\indicator\no_student',
],
],
];
@@ -0,0 +1,36 @@
<?php
// This file is part of Moodle - https://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/>.
/**
* Example of an invalid db/analytics.php file content used for unit tests.
*
* @package core_analytics
* @category test
* @copyright 2019 David Mudrák <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$models = [
[
'target' => 'test_target_course_level_shortname',
'indicators' => [
'\core_course\analytics\indicator\no_teacher',
],
'timesplitting' => '\non\existing\class\name',
],
];
@@ -0,0 +1,36 @@
<?php
// This file is part of Moodle - https://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/>.
/**
* Example of an invalid db/analytics.php file content used for unit tests.
*
* @package core_analytics
* @category test
* @copyright 2019 David Mudrák <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$models = [
[
'target' => 'test_target_course_level_shortname',
'indicators' => [
'\core_course\analytics\indicator\no_teacher',
],
'timesplitting' => 'local_customplugin_non_fully_qualified_class_name',
],
];
@@ -0,0 +1,32 @@
<?php
// This file is part of Moodle - https://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/>.
/**
* Example of an invalid db/analytics.php file content used for unit tests.
*
* @package core_analytics
* @category test
* @copyright 2019 David Mudrák <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$models = [
[
'target' => 'test_target_course_level_shortname',
],
];
@@ -0,0 +1,35 @@
<?php
// This file is part of Moodle - https://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/>.
/**
* Example of an invalid db/analytics.php file content used for unit tests.
*
* @package core_analytics
* @category test
* @copyright 2019 David Mudrák <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$models = [
[
'indicators' => [
'\core_course\analytics\indicator\no_teacher',
'\core_course\analytics\indicator\no_student',
],
],
];
@@ -0,0 +1,38 @@
<?php
// This file is part of Moodle - https://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/>.
/**
* Example of a valid db/analytics.php file content used for unit tests.
*
* @package core_analytics
* @category test
* @copyright 2019 David Mudrák <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$models = [
[
'target' => '\core_course\analytics\target\no_teaching',
'indicators' => [
'\core_course\analytics\indicator\no_teacher',
'\core_course\analytics\indicator\no_student',
],
'timesplitting' => '\core\analytics\time_splitting\single_range',
'enabled' => true,
],
];
+47
View File
@@ -0,0 +1,47 @@
<?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/>.
/**
* Test analyser.
*
* @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
*/
defined('MOODLE_INTERNAL') || die();
/**
* Test analyser.
*
* @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 test_analysis extends \core_analytics\analysis {
/**
* Overwritten to add a delay.
*
* @param \core_analytics\analysable $analysable
* @return array
*/
public function process_analysable(\core_analytics\analysable $analysable): array {
// Half a second.
sleep(1);
return parent::process_analysable($analysable);
}
}
+90
View File
@@ -0,0 +1,90 @@
<?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/>.
/**
* Test indicator.
*
* @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
*/
defined('MOODLE_INTERNAL') || die();
/**
* Test indicator.
*
* @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 test_indicator_discrete extends \core_analytics\local\indicator\discrete {
/**
* Returns the name.
*
* If there is a corresponding '_help' string this will be shown as well.
*
* @return \lang_string
*/
public static function get_name(): \lang_string {
// Using a string that exists and contains a corresponding '_help' string.
return new \lang_string('allowstealthmodules');
}
/**
* The different classes this discrete indicator provides.
* @return [type] [description]
*/
protected static function get_classes() {
return [0, 1, 2, 3, 4];
}
/**
* Just for testing.
*
* @param float $value
* @param string $subtype
* @return string
*/
public function get_calculation_outcome($value, $subtype = false) {
return self::OUTCOME_OK;
}
/**
* Custom indicator calculated value display as otherwise we would display meaningless numbers to users.
*
* @param float $value
* @param string $subtype
* @return string
*/
public function get_display_value($value, $subtype = false) {
return $value;
}
/**
* calculate_sample
*
* @param int $sampleid
* @param string $sampleorigin
* @param int $starttime
* @param int $endtime
* @return float
*/
protected function calculate_sample($sampleid, $sampleorigin, $starttime = false, $endtime = false) {
return 4;
}
}
+92
View File
@@ -0,0 +1,92 @@
<?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/>.
/**
* Test indicator.
*
* @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
*/
defined('MOODLE_INTERNAL') || die();
/**
* Test indicator.
*
* @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 test_indicator_fullname extends \core_analytics\local\indicator\linear {
/**
* Returns a lang_string object representing the name for the indicator.
*
* Used as column identificator.
*
* If there is a corresponding '_help' string this will be shown as well.
*
* @return \lang_string
*/
public static function get_name(): \lang_string {
// Using a string that exists and contains a corresponding '_help' string.
return new \lang_string('allowstealthmodules');
}
/**
* include_averages
*
* @return bool
*/
protected static function include_averages() {
return false;
}
/**
* required_sample_data
*
* @return string[]
*/
public static function required_sample_data() {
return array('course');
}
/**
* calculate_sample
*
* @param int $sampleid
* @param string $samplesorigin
* @param int $starttime
* @param int $endtime
* @return float
*/
protected function calculate_sample($sampleid, $samplesorigin, $starttime, $endtime) {
$course = $this->retrieve('course', $sampleid);
$firstchar = substr($course->fullname, 0, 1);
if ($firstchar === 'a') {
return self::MIN_VALUE;
} else if ($firstchar === 'b') {
return -0.2;
} else if ($firstchar === 'c') {
return 0.2;
} else {
return self::MAX_VALUE;
}
}
}
+62
View File
@@ -0,0 +1,62 @@
<?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/>.
/**
* Test indicator.
*
* @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
*/
defined('MOODLE_INTERNAL') || die();
/**
* Test indicator.
*
* @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 test_indicator_max extends \core_analytics\local\indicator\binary {
/**
* Returns a lang_string object representing the name for the indicator.
*
* Used as column identificator.
*
* If there is a corresponding '_help' string this will be shown as well.
*
* @return \lang_string
*/
public static function get_name(): \lang_string {
// Using a string that exists and contains a corresponding '_help' string.
return new \lang_string('allowstealthmodules');
}
/**
* calculate_sample
*
* @param int $sampleid
* @param string $samplesorigin
* @param int $starttime
* @param int $endtime
* @return float
*/
protected function calculate_sample($sampleid, $samplesorigin, $starttime, $endtime) {
return self::MAX_VALUE;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?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/>.
/**
* Test indicator.
*
* @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
*/
defined('MOODLE_INTERNAL') || die();
/**
* Test indicator.
*
* @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 test_indicator_min extends \core_analytics\local\indicator\binary {
/**
* Returns a lang_string object representing the name for the indicator.
*
* Used as column identificator.
*
* If there is a corresponding '_help' string this will be shown as well.
*
* @return \lang_string
*/
public static function get_name(): \lang_string {
// Using a string that exists and contains a corresponding '_help' string.
return new \lang_string('allowstealthmodules');
}
/**
* calculate_sample
*
* @param int $sampleid
* @param string $samplesorigin
* @param int $starttime
* @param int $endtime
* @return float
*/
protected function calculate_sample($sampleid, $samplesorigin, $starttime, $endtime) {
return self::MIN_VALUE;
}
}
+92
View File
@@ -0,0 +1,92 @@
<?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/>.
/**
* Multiclass test indicator.
*
* @package core_analytics
* @copyright 2019 Vlad Apetrei
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Multiclass test indicator.
*
* @package core_analytics
* @copyright 2019 Vlad Apetrei
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class test_indicator_multiclass extends \core_analytics\local\indicator\linear {
/**
* Returns a lang_string object representing the name for the indicator.
*
* Used as column identificator.
*
* If there is a corresponding '_help' string this will be shown as well.
*
* @return \lang_string
*/
public static function get_name(): \lang_string {
// Using a string that exists and contains a corresponding '_help' string.
return new \lang_string('allowstealthmodules');
}
/**
* include_averages
*
* @return bool
*/
protected static function include_averages() {
return false;
}
/**
* required_sample_data
*
* @return string[]
*/
public static function required_sample_data() {
return array('course');
}
/**
* calculate_sample
*
* @param int $sampleid
* @param string $samplesorigin
* @param int $starttime
* @param int $endtime
* @return float
*/
protected function calculate_sample($sampleid, $samplesorigin, $starttime, $endtime) {
$course = $this->retrieve('course', $sampleid);
$firstchar = substr($course->fullname, 0, 1);
if ($firstchar === 'a') {
return 1;
} else if ($firstchar === 'b') {
return -1;
} else if ($firstchar === 'c') {
return 1;
} else {
return self::MAX_VALUE;
}
}
}
+62
View File
@@ -0,0 +1,62 @@
<?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/>.
/**
* Test indicator. Always null.
*
* @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
*/
defined('MOODLE_INTERNAL') || die();
/**
* Test indicator. Always null.
*
* @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 test_indicator_null extends \core_analytics\local\indicator\binary {
/**
* Returns a lang_string object representing the name for the indicator.
*
* Used as column identificator.
*
* If there is a corresponding '_help' string this will be shown as well.
*
* @return \lang_string
*/
public static function get_name(): \lang_string {
// Using a string that exists and contains a corresponding '_help' string.
return new \lang_string('allowstealthmodules');
}
/**
* calculate_sample
*
* @param int $sampleid
* @param string $samplesorigin
* @param int $starttime
* @param int $endtime
* @return float
*/
protected function calculate_sample($sampleid, $samplesorigin, $starttime, $endtime) {
return null;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?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/>.
/**
* Test indicator.
*
* @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
*/
defined('MOODLE_INTERNAL') || die();
/**
* Test indicator.
*
* @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 test_indicator_random extends \core_analytics\local\indicator\linear {
/**
* Returns a lang_string object representing the name for the indicator.
*
* Used as column identificator.
*
* If there is a corresponding '_help' string this will be shown as well.
*
* @return \lang_string
*/
public static function get_name(): \lang_string {
// Using a string that exists and contains a corresponding '_help' string.
return new \lang_string('allowstealthmodules');
}
/**
* calculate_sample
*
* @param int $sampleid
* @param string $samplesorigin
* @param int $starttime
* @param int $endtime
* @return float
*/
protected function calculate_sample($sampleid, $samplesorigin, $starttime, $endtime) {
return mt_rand(-1, 1);
}
}
+147
View File
@@ -0,0 +1,147 @@
<?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/>.
/**
* Test analyser
*
* @package core
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Test analyser
*
* @package core
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class test_site_users_analyser extends \core_analytics\local\analyser\sitewide {
/**
* Samples origin is course table.
*
* @return string
*/
public function get_samples_origin() {
return 'user';
}
/**
* Returns the sample analysable
*
* @param int $sampleid
* @return \core_analytics\analysable
*/
public function get_sample_analysable($sampleid) {
return new \core_analytics\site();
}
/**
* Data this analyer samples provide.
*
* @return string[]
*/
protected function provided_sample_data() {
return array('user');
}
/**
* Returns the sample context.
*
* @param int $sampleid
* @return \context
*/
public function sample_access_context($sampleid) {
return \context_system::instance();
}
/**
* Returns all site courses.
*
* @param \core_analytics\analysable $site
* @return array
*/
public function get_all_samples(\core_analytics\analysable $site) {
global $DB;
$users = $DB->get_records('user');
$userids = array_keys($users);
$sampleids = array_combine($userids, $userids);
$users = array_map(function($user) {
return array('user' => $user);
}, $users);
return array($sampleids, $users);
}
/**
* Return all complete samples data from sample ids.
*
* @param int[] $sampleids
* @return array
*/
public function get_samples($sampleids) {
global $DB;
list($userssql, $params) = $DB->get_in_or_equal($sampleids, SQL_PARAMS_NAMED);
$users = $DB->get_records_select('user', "id {$userssql}", $params);
$userids = array_keys($users);
$sampleids = array_combine($userids, $userids);
$users = array_map(function($user) {
return array('user' => $user);
}, $users);
return array($sampleids, $users);
}
/**
* Returns the description of a sample.
*
* @param int $sampleid
* @param int $contextid
* @param array $sampledata
* @return array array(string, \renderable)
*/
public function sample_description($sampleid, $contextid, $sampledata) {
$description = fullname($sampledata['user']);
$userimage = new \pix_icon('i/user', get_string('user'));
return array($description, $userimage);
}
/**
* We need to delete associated data if a user requests his data to be deleted.
*
* @return bool
*/
public function processes_user_data() {
return true;
}
/**
* Join the samples origin table with the user id table.
*
* @param string $sampletablealias
* @return string
*/
public function join_sample_user($sampletablealias) {
return "JOIN {user} u ON u.id = {$sampletablealias}.sampleid";
}
}
@@ -0,0 +1,82 @@
<?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/>.
/**
* Test static target.
*
* @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
*/
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/test_target_shortname.php');
/**
* Test static target.
*
* Testing target extension to make it static and to use a different analyser
* just to try a different one. Method calculate_sample is exactly the same.
*
* @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 test_static_target_shortname extends test_target_shortname {
/**
* Returns a lang_string object representing the name for the indicator.
*
* Used as column identificator.
*
* If there is a corresponding '_help' string this will be shown as well.
*
* @return \lang_string
*/
public static function get_name(): \lang_string {
// Using a string that exists and contains a corresponding '_help' string.
return new \lang_string('allowstealthmodules');
}
/**
* based_on_assumptions
*
* @return bool
*/
public static function based_on_assumptions() {
return true;
}
/**
* Everything yep, this is just for testing.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @return bool
*/
public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool {
return true;
}
/**
* Different analyser just to test a different one.
*
* @return string
*/
public function get_analyser_class() {
return '\core\analytics\analyser\courses';
}
}
@@ -0,0 +1,46 @@
<?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/>.
/**
* Test target.
*
* @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
*/
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/test_target_shortname.php');
/**
* Test target.
*
* @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 test_target_course_level_shortname extends test_target_shortname {
/**
* get_analyser_class
*
* @return string
*/
public function get_analyser_class() {
return '\core\analytics\analyser\courses';
}
}
+60
View File
@@ -0,0 +1,60 @@
<?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/>.
/**
* Test target.
*
* @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
*/
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/test_target_site_users.php');
/**
* Test target.
*
* @package core_analytics
* @copyright 2018 David Monllaó {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class test_target_course_users extends test_target_site_users {
/**
* get_analyser_class
*
* @return string
*/
public function get_analyser_class() {
return '\core\analytics\analyser\student_enrolments';
}
/**
* Returns a lang_string object representing the name for the indicator.
*
* Used as column identificator.
*
* If there is a corresponding '_help' string this will be shown as well.
*
* @return \lang_string
*/
public static function get_name(): \lang_string {
// Using a string that exists and contains a corresponding '_help' string.
return new \lang_string('adminhelpedituser');
}
}
+159
View File
@@ -0,0 +1,159 @@
<?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/>.
/**
* Test target.
*
* @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
*/
defined('MOODLE_INTERNAL') || die();
/**
* Test target.
*
* @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 test_target_shortname extends \core_analytics\local\target\binary {
/**
* Returns a lang_string object representing the name for the indicator.
*
* Used as column identificator.
*
* If there is a corresponding '_help' string this will be shown as well.
*
* @return \lang_string
*/
public static function get_name(): \lang_string {
// Using a string that exists and contains a corresponding '_help' string.
return new \lang_string('allowstealthmodules');
}
/**
* predictions
*
* @var array
*/
protected $predictions = array();
/**
* get_analyser_class
*
* @return string
*/
public function get_analyser_class() {
return '\core\analytics\analyser\site_courses';
}
/**
* classes_description
*
* @return string[]
*/
public static function classes_description() {
return array(
'Course fullname first char is A',
'Course fullname first char is not A'
);
}
/**
* We don't want to discard results.
* @return float
*/
protected function min_prediction_score() {
return null;
}
/**
* We don't want to discard results.
* @return array
*/
public function ignored_predicted_classes() {
return array();
}
/**
* Only past stuff.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @return bool
*/
public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool {
return ($timesplitting instanceof \core_analytics\local\time_splitting\before_now);
}
/**
* is_valid_analysable
*
* @param \core_analytics\analysable $analysable
* @param bool $fortraining
* @return bool
*/
public function is_valid_analysable(\core_analytics\analysable $analysable, $fortraining = true) {
// This is testing, let's make things easy.
return true;
}
/**
* is_valid_sample
*
* @param int $sampleid
* @param \core_analytics\analysable $analysable
* @param bool $fortraining
* @return bool
*/
public function is_valid_sample($sampleid, \core_analytics\analysable $analysable, $fortraining = true) {
// We skip not-visible courses during training as a way to emulate the training data / prediction data difference.
// In normal circumstances is_valid_sample will return false when they receive a sample that can not be
// processed.
if (!$fortraining) {
return true;
}
$sample = $this->retrieve('course', $sampleid);
if ($sample->visible == 0) {
return false;
}
return true;
}
/**
* calculate_sample
*
* @param int $sampleid
* @param \core_analytics\analysable $analysable
* @param int $starttime
* @param int $endtime
* @return float
*/
protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) {
$sample = $this->retrieve('course', $sampleid);
$firstchar = substr($sample->shortname, 0, 1);
if ($firstchar === 'a') {
return 1;
} else {
return 0;
}
}
}
@@ -0,0 +1,211 @@
<?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/>.
/**
* Multi-class classifier target.
*
* @package core_analytics
* @copyright 2019 Apetrei Vlad
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Multi-class classifier target.
*
* @package core_analytics
* @copyright 2019 Apetrei Vlad
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class test_target_shortname_multiclass extends \core_analytics\local\target\discrete {
/**
* Returns a lang_string object representing the name for the indicator.
*
* Used as column identificator.
*
* If there is a corresponding '_help' string this will be shown as well.
*
* @return \lang_string
*/
public static function get_name(): \lang_string {
// Using a string that exists and contains a corresponding '_help' string.
return new \lang_string('allowstealthmodules');
}
/**
* predictions
*
* @var array
*/
protected $predictions = array();
/**
* is_linear
*
* @return bool
*/
public function is_linear() {
return false;
}
/**
* Returns the target discrete values.
*
* Only useful for targets using discrete values, must be overwriten if it is the case.
*
* @return array
*/
final public static function get_classes() {
return array(0, 1, 2);
}
/**
* Is the calculated value a positive outcome of this target?
*
* @param string $value
* @param string $ignoredsubtype
* @return int
*/
public function get_calculation_outcome($value, $ignoredsubtype = false) {
if (!self::is_a_class($value)) {
throw new \moodle_exception('errorpredictionformat', 'analytics');
}
if (in_array($value, $this->ignored_predicted_classes(), false)) {
// Just in case, if it is ignored the prediction should not even be recorded but if it would, it is ignored now,
// which should mean that is it nothing serious.
return self::OUTCOME_VERY_POSITIVE;
}
// By default binaries are danger when prediction = 1.
if ($value) {
return self::OUTCOME_VERY_NEGATIVE;
}
return self::OUTCOME_VERY_POSITIVE;
}
/**
* get_analyser_class
*
* @return string
*/
public function get_analyser_class() {
return '\core\analytics\analyser\site_courses';
}
/**
* We don't want to discard results.
* @return float
*/
protected function min_prediction_score() {
return null;
}
/**
* We don't want to discard results.
* @return array
*/
public function ignored_predicted_classes() {
return array();
}
/**
* is_valid_analysable
*
* @param \core_analytics\analysable $analysable
* @param bool $fortraining
* @return bool
*/
public function is_valid_analysable(\core_analytics\analysable $analysable, $fortraining = true) {
// This is testing, let's make things easy.
return true;
}
/**
* is_valid_sample
*
* @param int $sampleid
* @param \core_analytics\analysable $analysable
* @param bool $fortraining
* @return bool
*/
public function is_valid_sample($sampleid, \core_analytics\analysable $analysable, $fortraining = true) {
// We skip not-visible courses during training as a way to emulate the training data / prediction data difference.
// In normal circumstances is_valid_sample will return false when they receive a sample that can not be
// processed.
if (!$fortraining) {
return true;
}
$sample = $this->retrieve('course', $sampleid);
if ($sample->visible == 0) {
return false;
}
return true;
}
/**
* classes_description
*
* @return string[]
*/
protected static function classes_description() {
return array(
get_string('first class'),
get_string('second class'),
get_string('third class')
);
}
/**
* calculate_sample
*
* @param int $sampleid
* @param \core_analytics\analysable $analysable
* @param int $starttime
* @param int $endtime
* @return float
*/
protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) {
$sample = $this->retrieve('course', $sampleid);
$firstchar = substr($sample->shortname, 0, 1);
switch ($firstchar) {
case 'a':
return 0;
case 'b':
return 1;
case 'c':
return 2;
}
}
/**
* Can the provided time-splitting method be used on this target?.
*
* Time-splitting methods not matching the target requirements will not be selectable by models based on this target.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @return bool
*/
public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool {
return true;
}
}
+161
View File
@@ -0,0 +1,161 @@
<?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/>.
/**
* Test target.
*
* @package core_analytics
* @copyright 2018 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__ . '/test_site_users_analyser.php');
/**
* Test target.
*
* @package core_analytics
* @copyright 2018 David Monllaó {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class test_target_site_users extends \core_analytics\local\target\binary {
/**
* Returns a lang_string object representing the name for the indicator.
*
* Used as column identificator.
*
* If there is a corresponding '_help' string this will be shown as well.
*
* @return \lang_string
*/
public static function get_name(): \lang_string {
// Using a string that exists and contains a corresponding '_help' string.
return new \lang_string('adminhelplogs');
}
/**
* predictions
*
* @var array
*/
protected $predictions = array();
/**
* get_analyser_class
*
* @return string
*/
public function get_analyser_class() {
return 'test_site_users_analyser';
}
/**
* Everything yep, this is just for testing.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @return bool
*/
public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool {
return true;
}
/**
* classes_description
*
* @return string[]
*/
public static function classes_description() {
return array(
'firstname first char is A',
'firstname first char is not A'
);
}
/**
* We don't want to discard results.
* @return float
*/
protected function min_prediction_score() {
return null;
}
/**
* We don't want to discard results.
* @return array
*/
public function ignored_predicted_classes() {
return array();
}
/**
* is_valid_analysable
*
* @param \core_analytics\analysable $analysable
* @param bool $fortraining
* @return bool
*/
public function is_valid_analysable(\core_analytics\analysable $analysable, $fortraining = true) {
// This is testing, let's make things easy.
return true;
}
/**
* is_valid_sample
*
* @param int $sampleid
* @param \core_analytics\analysable $analysable
* @param bool $fortraining
* @return bool
*/
public function is_valid_sample($sampleid, \core_analytics\analysable $analysable, $fortraining = true) {
// We skip not-visible courses during training as a way to emulate the training data / prediction data difference.
// In normal circumstances is_valid_sample will return false when they receive a sample that can not be
// processed.
if (!$fortraining) {
return true;
}
$sample = $this->retrieve('user', $sampleid);
if ($sample->lastname == 'b') {
return false;
}
return true;
}
/**
* calculate_sample
*
* @param int $sampleid
* @param \core_analytics\analysable $analysable
* @param int $starttime
* @param int $endtime
* @return float
*/
protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) {
$sample = $this->retrieve('user', $sampleid);
$firstchar = substr($sample->firstname, 0, 1);
if ($firstchar === 'a') {
return 1;
} else {
return 0;
}
}
}
+52
View File
@@ -0,0 +1,52 @@
<?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/>.
/**
* Test time splitting.
*
* @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();
/**
* Test time splitting.
*
* @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
*/
class test_timesplitting_seconds extends \core_analytics\local\time_splitting\past_periodic {
/**
* Every second.
* @return \DateInterval
*/
public function periodicity() {
return new \DateInterval('PT1S');
}
/**
* Just to comply with the interface.
*
* @return \lang_string
*/
public static function get_name(): \lang_string {
return new \lang_string('error');
}
}
@@ -0,0 +1,52 @@
<?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/>.
/**
* Test time splitting.
*
* @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();
/**
* Test time splitting.
*
* @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
*/
class test_timesplitting_upcoming_seconds extends \core_analytics\local\time_splitting\upcoming_periodic {
/**
* Every second.
* @return \DateInterval
*/
public function periodicity() {
return new \DateInterval('PT1S');
}
/**
* Just to comply with the interface.
*
* @return \lang_string
*/
public static function get_name(): \lang_string {
return new \lang_string('error');
}
}
+93
View File
@@ -0,0 +1,93 @@
<?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/>.
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/fixtures/test_indicator_max.php');
require_once(__DIR__ . '/fixtures/test_indicator_discrete.php');
require_once(__DIR__ . '/fixtures/test_indicator_min.php');
/**
* Unit tests for the model.
*
* @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 indicator_test extends \advanced_testcase {
/**
* test_validate_calculated_value
*
* @param string $indicatorclass
* @param array $returnedvalue
* @dataProvider validate_calculated_value
* @return null
*/
public function test_validate_calculated_value($indicatorclass, $returnedvalue): void {
$indicator = new $indicatorclass();
list($values, $unused) = $indicator->calculate([1], 'notrelevanthere');
$this->assertEquals($returnedvalue, $values[0]);
}
/**
* Data provider for test_validate_calculated_value
*
* @return array
*/
public function validate_calculated_value() {
return [
'max' => ['test_indicator_max', [1]],
'min' => ['test_indicator_min', [-1]],
'discrete' => ['test_indicator_discrete', [0, 0, 0, 0, 1]],
];
}
/**
* test_validate_calculated_value_exceptions
*
* @param string $indicatorclass
* @param string $willreturn
* @dataProvider validate_calculated_value_exceptions
* @return null
*/
public function test_validate_calculated_value_exceptions($indicatorclass, $willreturn): void {
$indicator = new $indicatorclass();
$indicatormock = $this->getMockBuilder(get_class($indicator))
->onlyMethods(['calculate_sample'])
->getMock();
$indicatormock->method('calculate_sample')->willReturn($willreturn);
$this->expectException(\coding_exception::class);
list($values, $unused) = $indicatormock->calculate([1], 'notrelevanthere');
}
/**
* Data provider for test_validate_calculated_value_exceptions
*
* @return array
*/
public function validate_calculated_value_exceptions() {
return [
'max' => ['test_indicator_max', 2],
'min' => ['test_indicator_min', -2],
'discrete' => ['test_indicator_discrete', 7],
];
}
}
+554
View File
@@ -0,0 +1,554 @@
<?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/>.
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/fixtures/test_indicator_max.php');
require_once(__DIR__ . '/fixtures/test_indicator_min.php');
require_once(__DIR__ . '/fixtures/test_indicator_fullname.php');
require_once(__DIR__ . '/fixtures/test_target_course_level_shortname.php');
/**
* Unit tests for the core_analytics manager.
*
* @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
* @covers \core_analytics\manager
*/
class manager_test extends \advanced_testcase {
/**
* test_deleted_context
*/
public function test_deleted_context(): void {
global $DB;
$this->resetAfterTest(true);
$this->setAdminuser();
set_config('enabled_stores', 'logstore_standard', 'tool_log');
$target = \core_analytics\manager::get_target('test_target_course_level_shortname');
$indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_fullname');
foreach ($indicators as $key => $indicator) {
$indicators[$key] = \core_analytics\manager::get_indicator($indicator);
}
$model = \core_analytics\model::create($target, $indicators);
$modelobj = $model->get_model_obj();
$coursepredict1 = $this->getDataGenerator()->create_course(array('visible' => 0));
$coursepredict2 = $this->getDataGenerator()->create_course(array('visible' => 0));
$coursetrain1 = $this->getDataGenerator()->create_course(array('visible' => 1));
$coursetrain2 = $this->getDataGenerator()->create_course(array('visible' => 1));
$model->enable('\core\analytics\time_splitting\no_splitting');
$model->train();
$model->predict();
// Generate a prediction action to confirm that it is deleted when there is an important update.
$predictions = $DB->get_records('analytics_predictions');
$prediction = reset($predictions);
$prediction = new \core_analytics\prediction($prediction, array('whatever' => 'not used'));
$prediction->action_executed(\core_analytics\prediction::ACTION_USEFUL, $model->get_target());
$predictioncontextid = $prediction->get_prediction_data()->contextid;
$npredictions = $DB->count_records('analytics_predictions', array('contextid' => $predictioncontextid));
$npredictionactions = $DB->count_records('analytics_prediction_actions',
array('predictionid' => $prediction->get_prediction_data()->id));
$nindicatorcalc = $DB->count_records('analytics_indicator_calc', array('contextid' => $predictioncontextid));
\core_analytics\manager::cleanup();
// Nothing is incorrectly deleted.
$this->assertEquals($npredictions, $DB->count_records('analytics_predictions',
array('contextid' => $predictioncontextid)));
$this->assertEquals($npredictionactions, $DB->count_records('analytics_prediction_actions',
array('predictionid' => $prediction->get_prediction_data()->id)));
$this->assertEquals($nindicatorcalc, $DB->count_records('analytics_indicator_calc',
array('contextid' => $predictioncontextid)));
// Now we delete a context, the course predictions and prediction actions should be deleted.
$deletedcontext = \context::instance_by_id($predictioncontextid);
delete_course($deletedcontext->instanceid, false);
\core_analytics\manager::cleanup();
$this->assertEmpty($DB->count_records('analytics_predictions', array('contextid' => $predictioncontextid)));
$this->assertEmpty($DB->count_records('analytics_prediction_actions',
array('predictionid' => $prediction->get_prediction_data()->id)));
$this->assertEmpty($DB->count_records('analytics_indicator_calc', array('contextid' => $predictioncontextid)));
set_config('enabled_stores', '', 'tool_log');
get_log_manager(true);
}
/**
* test_deleted_analysable
*/
public function test_deleted_analysable(): void {
global $DB;
$this->resetAfterTest(true);
$this->setAdminuser();
set_config('enabled_stores', 'logstore_standard', 'tool_log');
$target = \core_analytics\manager::get_target('test_target_course_level_shortname');
$indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_fullname');
foreach ($indicators as $key => $indicator) {
$indicators[$key] = \core_analytics\manager::get_indicator($indicator);
}
$model = \core_analytics\model::create($target, $indicators);
$modelobj = $model->get_model_obj();
$coursepredict1 = $this->getDataGenerator()->create_course(array('visible' => 0));
$coursepredict2 = $this->getDataGenerator()->create_course(array('visible' => 0));
$coursetrain1 = $this->getDataGenerator()->create_course(array('visible' => 1));
$coursetrain2 = $this->getDataGenerator()->create_course(array('visible' => 1));
$model->enable('\core\analytics\time_splitting\no_splitting');
$model->train();
$model->predict();
$this->assertNotEmpty($DB->count_records('analytics_predict_samples'));
$this->assertNotEmpty($DB->count_records('analytics_train_samples'));
$this->assertNotEmpty($DB->count_records('analytics_used_analysables'));
// Now we delete an analysable, stored predict and training samples should be deleted.
$deletedcontext = \context_course::instance($coursepredict1->id);
delete_course($coursepredict1, false);
\core_analytics\manager::cleanup();
$this->assertEmpty($DB->count_records('analytics_predict_samples', array('analysableid' => $coursepredict1->id)));
$this->assertEmpty($DB->count_records('analytics_train_samples', array('analysableid' => $coursepredict1->id)));
$this->assertEmpty($DB->count_records('analytics_used_analysables', array('analysableid' => $coursepredict1->id)));
set_config('enabled_stores', '', 'tool_log');
get_log_manager(true);
}
/**
* Tests for the {@link \core_analytics\manager::load_default_models_for_component()} implementation.
*/
public function test_load_default_models_for_component(): void {
$this->resetAfterTest();
// Attempting to load builtin models should always work without throwing exception.
\core_analytics\manager::load_default_models_for_component('core');
// Attempting to load from a core subsystem without its own subsystem directory.
$this->assertSame([], \core_analytics\manager::load_default_models_for_component('core_access'));
// Attempting to load from a non-existing subsystem.
$this->assertSame([], \core_analytics\manager::load_default_models_for_component('core_nonexistingsubsystem'));
// Attempting to load from a non-existing plugin of a known plugin type.
$this->assertSame([], \core_analytics\manager::load_default_models_for_component('mod_foobarbazquaz12240996776'));
// Attempting to load from a non-existing plugin type.
$this->assertSame([], \core_analytics\manager::load_default_models_for_component('foo_bar2776327736558'));
}
/**
* Tests for the {@link \core_analytics\manager::load_default_models_for_all_components()} implementation.
*/
public function test_load_default_models_for_all_components(): void {
$this->resetAfterTest();
$models = \core_analytics\manager::load_default_models_for_all_components();
$this->assertTrue(is_array($models['core']));
$this->assertNotEmpty($models['core']);
$this->assertNotEmpty($models['core'][0]['target']);
$this->assertNotEmpty($models['core'][0]['indicators']);
}
/**
* Tests for the successful execution of the {@link \core_analytics\manager::validate_models_declaration()}.
*/
public function test_validate_models_declaration(): void {
$this->resetAfterTest();
// This is expected to run without an exception.
$models = $this->load_models_from_fixture_file('no_teaching');
\core_analytics\manager::validate_models_declaration($models);
}
/**
* Tests for the exceptions thrown by {@link \core_analytics\manager::validate_models_declaration()}.
*
* @dataProvider validate_models_declaration_exceptions_provider
* @param array $models Models declaration.
* @param string $exception Expected coding exception message.
*/
public function test_validate_models_declaration_exceptions(array $models, string $exception): void {
$this->resetAfterTest();
$this->expectException(\coding_exception::class);
$this->expectExceptionMessage($exception);
\core_analytics\manager::validate_models_declaration($models);
}
/**
* Data provider for the {@link self::test_validate_models_declaration_exceptions()}.
*
* @return array of (string)testcase => [(array)models, (string)expected exception message]
*/
public function validate_models_declaration_exceptions_provider() {
return [
'missing_target' => [
$this->load_models_from_fixture_file('missing_target'),
'Missing target declaration',
],
'invalid_target' => [
$this->load_models_from_fixture_file('invalid_target'),
'Invalid target classname',
],
'missing_indicators' => [
$this->load_models_from_fixture_file('missing_indicators'),
'Missing indicators declaration',
],
'invalid_indicators' => [
$this->load_models_from_fixture_file('invalid_indicators'),
'Invalid indicator classname',
],
'invalid_time_splitting' => [
$this->load_models_from_fixture_file('invalid_time_splitting'),
'Invalid time splitting classname',
],
'invalid_time_splitting_fq' => [
$this->load_models_from_fixture_file('invalid_time_splitting_fq'),
'Expecting fully qualified time splitting classname',
],
'invalid_enabled' => [
$this->load_models_from_fixture_file('invalid_enabled'),
'Cannot enable a model without time splitting method specified',
],
];
}
/**
* Loads models as declared in the given fixture file.
*
* @param string $filename
* @return array
*/
protected function load_models_from_fixture_file(string $filename) {
global $CFG;
$models = null;
require($CFG->dirroot.'/analytics/tests/fixtures/db_analytics_php/'.$filename.'.php');
return $models;
}
/**
* Test the implementation of the {@link \core_analytics\manager::create_declared_model()}.
*/
public function test_create_declared_model(): void {
global $DB;
$this->resetAfterTest();
$this->setAdminuser();
$declaration = [
'target' => 'test_target_course_level_shortname',
'indicators' => [
'test_indicator_max',
'test_indicator_min',
'test_indicator_fullname',
],
];
$declarationwithtimesplitting = array_merge($declaration, [
'timesplitting' => '\core\analytics\time_splitting\no_splitting',
]);
$declarationwithtimesplittingenabled = array_merge($declarationwithtimesplitting, [
'enabled' => true,
]);
// Check that no such model exists yet.
$target = \core_analytics\manager::get_target('test_target_course_level_shortname');
$this->assertEquals(0, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
$this->assertFalse(\core_analytics\model::exists($target));
// Check that the model is created.
$created = \core_analytics\manager::create_declared_model($declaration);
$this->assertTrue($created instanceof \core_analytics\model);
$this->assertTrue(\core_analytics\model::exists($target));
$this->assertEquals(1, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
$modelid = $created->get_id();
// Check that created models are disabled by default.
$existing = new \core_analytics\model($modelid);
$this->assertEquals(0, $existing->get_model_obj()->enabled);
$this->assertEquals(0, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST));
// Let the admin enable the model.
$existing->enable('\core\analytics\time_splitting\no_splitting');
$this->assertEquals(1, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST));
// Check that further calls create a new model.
$repeated = \core_analytics\manager::create_declared_model($declaration);
$this->assertTrue($repeated instanceof \core_analytics\model);
$this->assertEquals(2, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
// Delete the models.
$existing->delete();
$repeated->delete();
$this->assertEquals(0, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
$this->assertFalse(\core_analytics\model::exists($target));
// Create it again, this time with time splitting method specified.
$created = \core_analytics\manager::create_declared_model($declarationwithtimesplitting);
$this->assertTrue($created instanceof \core_analytics\model);
$this->assertTrue(\core_analytics\model::exists($target));
$this->assertEquals(1, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
$modelid = $created->get_id();
// Even if the time splitting method was specified, the model is still not enabled automatically.
$existing = new \core_analytics\model($modelid);
$this->assertEquals(0, $existing->get_model_obj()->enabled);
$this->assertEquals(0, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST));
$existing->delete();
// Let's define the model so that it is enabled by default.
$enabled = \core_analytics\manager::create_declared_model($declarationwithtimesplittingenabled);
$this->assertTrue($enabled instanceof \core_analytics\model);
$this->assertTrue(\core_analytics\model::exists($target));
$this->assertEquals(1, $DB->count_records('analytics_models', ['target' => $target->get_id()]));
$modelid = $enabled->get_id();
$existing = new \core_analytics\model($modelid);
$this->assertEquals(1, $existing->get_model_obj()->enabled);
$this->assertEquals(1, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST));
// Let the admin disable the model.
$existing->update(0, false, false);
$this->assertEquals(0, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST));
}
/**
* Test the implementation of the {@link \core_analytics\manager::update_default_models_for_component()}.
*/
public function test_update_default_models_for_component(): void {
$this->resetAfterTest();
$this->setAdminuser();
$noteaching = \core_analytics\manager::get_target('\core_course\analytics\target\no_teaching');
$dropout = \core_analytics\manager::get_target('\core_course\analytics\target\course_dropout');
$upcomingactivities = \core_analytics\manager::get_target('\core_user\analytics\target\upcoming_activities_due');
$norecentaccesses = \core_analytics\manager::get_target('\core_course\analytics\target\no_recent_accesses');
$noaccesssincestart = \core_analytics\manager::get_target('\core_course\analytics\target\no_access_since_course_start');
$this->assertTrue(\core_analytics\model::exists($noteaching));
$this->assertTrue(\core_analytics\model::exists($dropout));
$this->assertTrue(\core_analytics\model::exists($upcomingactivities));
$this->assertTrue(\core_analytics\model::exists($norecentaccesses));
$this->assertTrue(\core_analytics\model::exists($noaccesssincestart));
foreach (\core_analytics\manager::get_all_models() as $model) {
$model->delete();
}
$this->assertFalse(\core_analytics\model::exists($noteaching));
$this->assertFalse(\core_analytics\model::exists($dropout));
$this->assertFalse(\core_analytics\model::exists($upcomingactivities));
$this->assertFalse(\core_analytics\model::exists($norecentaccesses));
$this->assertFalse(\core_analytics\model::exists($noaccesssincestart));
$updated = \core_analytics\manager::update_default_models_for_component('moodle');
$this->assertEquals(5, count($updated));
$this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
$this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
$this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
$this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
$this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
$this->assertTrue(\core_analytics\model::exists($noteaching));
$this->assertTrue(\core_analytics\model::exists($dropout));
$this->assertTrue(\core_analytics\model::exists($upcomingactivities));
$this->assertTrue(\core_analytics\model::exists($norecentaccesses));
$this->assertTrue(\core_analytics\model::exists($noaccesssincestart));
$repeated = \core_analytics\manager::update_default_models_for_component('moodle');
$this->assertSame([], $repeated);
}
/**
* test_get_time_splitting_methods description
* @return null
*/
public function test_get_time_splitting_methods(): void {
$this->resetAfterTest(true);
$all = \core_analytics\manager::get_all_time_splittings();
$this->assertArrayHasKey('\core\analytics\time_splitting\upcoming_week', $all);
$this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $all);
$allforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(true);
$this->assertArrayNotHasKey('\core\analytics\time_splitting\upcoming_week', $allforevaluation);
$this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $allforevaluation);
$defaultforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(false);
$this->assertArrayNotHasKey('\core\analytics\time_splitting\upcoming_week', $defaultforevaluation);
$this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $defaultforevaluation);
$sometimesplittings = '\core\analytics\time_splitting\single_range,' .
'\core\analytics\time_splitting\tenths';
set_config('defaulttimesplittingsevaluation', $sometimesplittings, 'analytics');
$defaultforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(false);
$this->assertArrayNotHasKey('\core\analytics\time_splitting\quarters', $defaultforevaluation);
}
/**
* Test the implementation of the {@link \core_analytics\manager::model_declaration_identifier()}.
*/
public function test_model_declaration_identifier(): void {
$noteaching1 = $this->load_models_from_fixture_file('no_teaching');
$noteaching2 = $this->load_models_from_fixture_file('no_teaching');
$noteaching3 = $this->load_models_from_fixture_file('no_teaching');
// Same model declaration should always lead to same identifier.
$this->assertEquals(
\core_analytics\manager::model_declaration_identifier(reset($noteaching1)),
\core_analytics\manager::model_declaration_identifier(reset($noteaching2))
);
// If something is changed, the identifier should change, too.
$noteaching2[0]['target'] .= '_';
$this->assertNotEquals(
\core_analytics\manager::model_declaration_identifier(reset($noteaching1)),
\core_analytics\manager::model_declaration_identifier(reset($noteaching2))
);
$noteaching3[0]['indicators'][] = '\core_analytics\local\indicator\binary';
$this->assertNotEquals(
\core_analytics\manager::model_declaration_identifier(reset($noteaching1)),
\core_analytics\manager::model_declaration_identifier(reset($noteaching3))
);
// The identifier is supposed to contain PARAM_ALPHANUM only.
$this->assertEquals(
\core_analytics\manager::model_declaration_identifier(reset($noteaching1)),
clean_param(\core_analytics\manager::model_declaration_identifier(reset($noteaching1)), PARAM_ALPHANUM)
);
$this->assertEquals(
\core_analytics\manager::model_declaration_identifier(reset($noteaching2)),
clean_param(\core_analytics\manager::model_declaration_identifier(reset($noteaching2)), PARAM_ALPHANUM)
);
$this->assertEquals(
\core_analytics\manager::model_declaration_identifier(reset($noteaching3)),
clean_param(\core_analytics\manager::model_declaration_identifier(reset($noteaching3)), PARAM_ALPHANUM)
);
}
/**
* Tests for the {@link \core_analytics\manager::get_declared_target_and_indicators_instances()}.
*/
public function test_get_declared_target_and_indicators_instances(): void {
$this->resetAfterTest();
$definition = $this->load_models_from_fixture_file('no_teaching');
list($target, $indicators) = \core_analytics\manager::get_declared_target_and_indicators_instances($definition[0]);
$this->assertTrue($target instanceof \core_analytics\local\target\base);
$this->assertNotEmpty($indicators);
$this->assertContainsOnlyInstancesOf(\core_analytics\local\indicator\base::class, $indicators);
}
/**
* test_get_potential_context_restrictions description
*/
public function test_get_potential_context_restrictions(): void {
$this->resetAfterTest();
// No potential context restrictions.
$this->assertFalse(\core_analytics\manager::get_potential_context_restrictions([]));
$defaultcategory = \core_course_category::get_default();
$defaultcategorycontext = $defaultcategory->get_context();
// Include the all context levels so the misc. category get included.
$this->assertEquals([
$defaultcategorycontext->id => "Category: {$defaultcategory->name}",
], manager::get_potential_context_restrictions());
$category = $this->getDataGenerator()->create_category(['name' => 'My category']);
$categorycontext = $category->get_context();
$courseone = $this->getDataGenerator()->create_course(['fullname' => 'Course one', 'shortname' => 'CS1']);
$courseonecontext = \context_course::instance($courseone->id);
$coursetwo = $this->getDataGenerator()->create_course(['fullname' => 'Course two', 'shortname' => 'CS2']);
$coursetwocontext = \context_course::instance($coursetwo->id);
// All context levels.
$this->assertEqualsCanonicalizing([
$defaultcategorycontext->id => "Category: {$defaultcategory->name}",
$categorycontext->id => "Category: {$category->name}",
$courseonecontext->id => "Course: {$courseone->shortname}",
$coursetwocontext->id => "Course: {$coursetwo->shortname}",
], manager::get_potential_context_restrictions());
// All category/course context levels.
$this->assertEqualsCanonicalizing([
$defaultcategorycontext->id => "Category: {$defaultcategory->name}",
$categorycontext->id => "Category: {$category->name}",
$courseonecontext->id => "Course: {$courseone->shortname}",
$coursetwocontext->id => "Course: {$coursetwo->shortname}",
], manager::get_potential_context_restrictions([CONTEXT_COURSECAT, CONTEXT_COURSE]));
// All category context levels.
$this->assertEqualsCanonicalizing([
$defaultcategorycontext->id => "Category: {$defaultcategory->name}",
$categorycontext->id => "Category: {$category->name}",
], manager::get_potential_context_restrictions([CONTEXT_COURSECAT]));
// Filtered category context levels.
$this->assertEquals([
$categorycontext->id => "Category: {$category->name}",
], manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'My cat'));
$this->assertEmpty(manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'nothing'));
// All course context levels.
$this->assertEqualsCanonicalizing([
$courseonecontext->id => "Course: {$courseone->shortname}",
$coursetwocontext->id => "Course: {$coursetwo->shortname}",
], manager::get_potential_context_restrictions([CONTEXT_COURSE]));
// Filtered course context levels.
$this->assertEquals([
$courseonecontext->id => "Course: {$courseone->shortname}",
], manager::get_potential_context_restrictions([CONTEXT_COURSE], 'one'));
$this->assertEmpty(manager::get_potential_context_restrictions([CONTEXT_COURSE], 'nothing'));
}
}
+608
View File
@@ -0,0 +1,608 @@
<?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 model.
*
* @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
*/
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/fixtures/test_indicator_max.php');
require_once(__DIR__ . '/fixtures/test_indicator_min.php');
require_once(__DIR__ . '/fixtures/test_indicator_fullname.php');
require_once(__DIR__ . '/fixtures/test_target_shortname.php');
require_once(__DIR__ . '/fixtures/test_static_target_shortname.php');
require_once(__DIR__ . '/fixtures/test_target_course_level_shortname.php');
require_once(__DIR__ . '/fixtures/test_analysis.php');
/**
* Unit tests for the model.
*
* @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 model_test extends \advanced_testcase {
/** @var model Store Model. */
protected $model;
/** @var \stdClass Store model object. */
protected $modelobj;
public function setUp(): void {
$this->setAdminUser();
$target = \core_analytics\manager::get_target('test_target_shortname');
$indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_fullname');
foreach ($indicators as $key => $indicator) {
$indicators[$key] = \core_analytics\manager::get_indicator($indicator);
}
$this->model = testable_model::create($target, $indicators);
$this->modelobj = $this->model->get_model_obj();
}
public function test_enable(): void {
$this->resetAfterTest(true);
$this->assertEquals(0, $this->model->get_model_obj()->enabled);
$this->assertEquals(0, $this->model->get_model_obj()->trained);
$this->assertEquals('', $this->model->get_model_obj()->timesplitting);
$this->model->enable('\core\analytics\time_splitting\quarters');
$this->assertEquals(1, $this->model->get_model_obj()->enabled);
$this->assertEquals(0, $this->model->get_model_obj()->trained);
$this->assertEquals('\core\analytics\time_splitting\quarters', $this->model->get_model_obj()->timesplitting);
}
public function test_create(): void {
$this->resetAfterTest(true);
$target = \core_analytics\manager::get_target('\core_course\analytics\target\course_dropout');
$indicators = array(
\core_analytics\manager::get_indicator('\core\analytics\indicator\any_write_action'),
\core_analytics\manager::get_indicator('\core\analytics\indicator\read_actions')
);
$model = \core_analytics\model::create($target, $indicators);
$this->assertInstanceOf('\core_analytics\model', $model);
}
/**
* test_delete
*/
public function test_delete(): void {
global $DB;
$this->resetAfterTest(true);
set_config('enabled_stores', 'logstore_standard', 'tool_log');
$coursepredict1 = $this->getDataGenerator()->create_course(array('visible' => 0));
$coursepredict2 = $this->getDataGenerator()->create_course(array('visible' => 0));
$coursetrain1 = $this->getDataGenerator()->create_course(array('visible' => 1));
$coursetrain2 = $this->getDataGenerator()->create_course(array('visible' => 1));
$this->model->enable('\core\analytics\time_splitting\single_range');
$this->model->train();
$this->model->predict();
// Fake evaluation results record to check that it is actually deleted.
$this->add_fake_log();
$modeloutputdir = $this->model->get_output_dir(array(), true);
$this->assertTrue(is_dir($modeloutputdir));
// Generate a prediction action to confirm that it is deleted when there is an important update.
$predictions = $DB->get_records('analytics_predictions');
$prediction = reset($predictions);
$prediction = new \core_analytics\prediction($prediction, array('whatever' => 'not used'));
$prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target());
$this->model->delete();
$this->assertEmpty($DB->count_records('analytics_models', array('id' => $this->modelobj->id)));
$this->assertEmpty($DB->count_records('analytics_models_log', array('modelid' => $this->modelobj->id)));
$this->assertEmpty($DB->count_records('analytics_predictions'));
$this->assertEmpty($DB->count_records('analytics_prediction_actions'));
$this->assertEmpty($DB->count_records('analytics_train_samples'));
$this->assertEmpty($DB->count_records('analytics_predict_samples'));
$this->assertEmpty($DB->count_records('analytics_used_files'));
$this->assertFalse(is_dir($modeloutputdir));
set_config('enabled_stores', '', 'tool_log');
get_log_manager(true);
}
/**
* test_clear
*/
public function test_clear(): void {
global $DB;
$this->resetAfterTest(true);
set_config('enabled_stores', 'logstore_standard', 'tool_log');
$coursepredict1 = $this->getDataGenerator()->create_course(array('visible' => 0));
$coursepredict2 = $this->getDataGenerator()->create_course(array('visible' => 0));
$coursetrain1 = $this->getDataGenerator()->create_course(array('visible' => 1));
$coursetrain2 = $this->getDataGenerator()->create_course(array('visible' => 1));
$this->model->enable('\core\analytics\time_splitting\single_range');
$this->model->train();
$this->model->predict();
// Fake evaluation results record to check that it is actually deleted.
$this->add_fake_log();
// Generate a prediction action to confirm that it is deleted when there is an important update.
$predictions = $DB->get_records('analytics_predictions');
$prediction = reset($predictions);
$prediction = new \core_analytics\prediction($prediction, array('whatever' => 'not used'));
$prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target());
$modelversionoutputdir = $this->model->get_output_dir();
$this->assertTrue(is_dir($modelversionoutputdir));
// Update to an empty time splitting method to force model::clear execution.
$this->model->clear();
$this->assertFalse(is_dir($modelversionoutputdir));
// Check that most of the stuff got deleted.
$this->assertEquals(1, $DB->count_records('analytics_models', array('id' => $this->modelobj->id)));
$this->assertEquals(1, $DB->count_records('analytics_models_log', array('modelid' => $this->modelobj->id)));
$this->assertEmpty($DB->count_records('analytics_predictions'));
$this->assertEmpty($DB->count_records('analytics_prediction_actions'));
$this->assertEmpty($DB->count_records('analytics_train_samples'));
$this->assertEmpty($DB->count_records('analytics_predict_samples'));
$this->assertEmpty($DB->count_records('analytics_used_files'));
// Check that the model is marked as not trained after clearing (as it is not a static one).
$this->assertEquals(0, $DB->get_field('analytics_models', 'trained', array('id' => $this->modelobj->id)));
set_config('enabled_stores', '', 'tool_log');
get_log_manager(true);
}
/**
* Test behaviour of {\core_analytics\model::clear()} for static models.
*/
public function test_clear_static(): void {
global $DB;
$this->resetAfterTest();
$statictarget = new \test_static_target_shortname();
$indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max');
$model = \core_analytics\model::create($statictarget, $indicators, '\core\analytics\time_splitting\quarters');
$modelobj = $model->get_model_obj();
// Static models are always considered trained.
$this->assertEquals(1, $DB->get_field('analytics_models', 'trained', array('id' => $modelobj->id)));
$model->clear();
// Check that the model is still marked as trained even after clearing.
$this->assertEquals(1, $DB->get_field('analytics_models', 'trained', array('id' => $modelobj->id)));
}
public function test_model_manager(): void {
$this->resetAfterTest(true);
$this->assertCount(3, $this->model->get_indicators());
$this->assertInstanceOf('\core_analytics\local\target\binary', $this->model->get_target());
// Using evaluation as the model is not yet enabled.
$this->model->init_analyser(array('evaluation' => true));
$this->assertInstanceOf('\core_analytics\local\analyser\base', $this->model->get_analyser());
$this->model->enable('\core\analytics\time_splitting\quarters');
$this->assertInstanceOf('\core\analytics\analyser\site_courses', $this->model->get_analyser());
}
public function test_output_dir(): void {
$this->resetAfterTest(true);
$dir = make_request_directory();
set_config('modeloutputdir', $dir, 'analytics');
$modeldir = $dir . DIRECTORY_SEPARATOR . $this->modelobj->id . DIRECTORY_SEPARATOR . $this->modelobj->version;
$this->assertEquals($modeldir, $this->model->get_output_dir());
$this->assertEquals($modeldir . DIRECTORY_SEPARATOR . 'testing', $this->model->get_output_dir(array('testing')));
}
public function test_unique_id(): void {
global $DB;
$this->resetAfterTest(true);
$originaluniqueid = $this->model->get_unique_id();
// Same id across instances.
$this->model = new testable_model($this->modelobj);
$this->assertEquals($originaluniqueid, $this->model->get_unique_id());
// We will restore it later.
$originalversion = $this->modelobj->version;
// Generates a different id if timemodified changes.
$this->modelobj->version = $this->modelobj->version + 10;
$DB->update_record('analytics_models', $this->modelobj);
$this->model = new testable_model($this->modelobj);
$this->assertNotEquals($originaluniqueid, $this->model->get_unique_id());
// Restore original timemodified to continue testing.
$this->modelobj->version = $originalversion;
$DB->update_record('analytics_models', $this->modelobj);
// Same when updating through an action that changes the model.
$this->model = new testable_model($this->modelobj);
$this->model->mark_as_trained();
$this->assertEquals($originaluniqueid, $this->model->get_unique_id());
// Wait for the current timestamp to change.
$this->waitForSecond();
$this->model->enable('\core\analytics\time_splitting\deciles');
$this->assertNotEquals($originaluniqueid, $this->model->get_unique_id());
$uniqueid = $this->model->get_unique_id();
// Wait for the current timestamp to change.
$this->waitForSecond();
$this->model->enable('\core\analytics\time_splitting\quarters');
$this->assertNotEquals($originaluniqueid, $this->model->get_unique_id());
$this->assertNotEquals($uniqueid, $this->model->get_unique_id());
}
/**
* test_exists
*
* @return void
*/
public function test_exists(): void {
$this->resetAfterTest(true);
$target = \core_analytics\manager::get_target('\core_course\analytics\target\no_teaching');
$this->assertTrue(\core_analytics\model::exists($target));
foreach (\core_analytics\manager::get_all_models() as $model) {
$model->delete();
}
$this->assertFalse(\core_analytics\model::exists($target));
}
/**
* test_model_timelimit
*
* @return null
*/
public function test_model_timelimit(): void {
global $DB;
$this->resetAfterTest(true);
set_config('modeltimelimit', 2, 'analytics');
$courses = array();
for ($i = 0; $i < 5; $i++) {
$course = $this->getDataGenerator()->create_course();
$analysable = new \core_analytics\course($course);
$courses[$analysable->get_id()] = $course;
}
$target = new \test_target_course_level_shortname();
$analyser = new \core\analytics\analyser\courses(1, $target, [], [], []);
$result = new \core_analytics\local\analysis\result_array(1, false, []);
$analysis = new \test_analysis($analyser, false, $result);
// Each analysable element takes 0.5 secs minimum (test_analysis), so the max (and likely) number of analysable
// elements that will be processed is 2.
$analysis->run();
$params = array('modelid' => 1, 'action' => 'prediction');
$this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params));
$analysis->run();
$this->assertLessThanOrEqual(4, $DB->count_records('analytics_used_analysables', $params));
// Check that analysable elements have been processed following the analyser order
// (course->sortorder here). We can not check this nicely after next get_unlabelled_data round
// because the first analysed element will be analysed again.
$analysedelems = $DB->get_records('analytics_used_analysables', $params, 'timeanalysed ASC');
// Just a default for the first checked element.
$last = (object)['sortorder' => PHP_INT_MAX];
foreach ($analysedelems as $analysed) {
if ($courses[$analysed->analysableid]->sortorder > $last->sortorder) {
$this->fail('Analysable elements have not been analysed sorted by course sortorder.');
}
$last = $courses[$analysed->analysableid];
}
// No time limit now to process the rest.
set_config('modeltimelimit', 1000, 'analytics');
$analysis->run();
$this->assertEquals(5, $DB->count_records('analytics_used_analysables', $params));
// New analysable elements are immediately pulled.
$this->getDataGenerator()->create_course();
$analysis->run();
$this->assertEquals(6, $DB->count_records('analytics_used_analysables', $params));
// Training and prediction data do not get mixed.
$result = new \core_analytics\local\analysis\result_array(1, false, []);
$analysis = new \test_analysis($analyser, false, $result);
$analysis->run();
$params = array('modelid' => 1, 'action' => 'training');
$this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params));
}
/**
* Test model_config::get_class_component.
*/
public function test_model_config_get_class_component(): void {
$this->resetAfterTest(true);
$this->assertEquals('core',
\core_analytics\model_config::get_class_component('\\core\\analytics\\indicator\\read_actions'));
$this->assertEquals('core',
\core_analytics\model_config::get_class_component('core\\analytics\\indicator\\read_actions'));
$this->assertEquals('core',
\core_analytics\model_config::get_class_component('\\core_course\\analytics\\indicator\\completion_enabled'));
$this->assertEquals('mod_forum',
\core_analytics\model_config::get_class_component('\\mod_forum\\analytics\\indicator\\cognitive_depth'));
$this->assertEquals('core', \core_analytics\model_config::get_class_component('\\core_class'));
}
/**
* Test that import_model import models' configurations.
*/
public function test_import_model_config(): void {
$this->resetAfterTest(true);
$this->model->enable('\\core\\analytics\\time_splitting\\quarters');
$zipfilepath = $this->model->export_model('yeah-config.zip');
$this->modelobj = $this->model->get_model_obj();
$importedmodelobj = \core_analytics\model::import_model($zipfilepath)->get_model_obj();
$this->assertSame($this->modelobj->target, $importedmodelobj->target);
$this->assertSame($this->modelobj->indicators, $importedmodelobj->indicators);
$this->assertSame($this->modelobj->timesplitting, $importedmodelobj->timesplitting);
$predictionsprocessor = $this->model->get_predictions_processor();
$this->assertSame('\\' . get_class($predictionsprocessor), $importedmodelobj->predictionsprocessor);
}
/**
* Test can export configuration
*/
public function test_can_export_configuration(): void {
$this->resetAfterTest(true);
// No time splitting method.
$this->assertFalse($this->model->can_export_configuration());
$this->model->enable('\\core\\analytics\\time_splitting\\quarters');
$this->assertTrue($this->model->can_export_configuration());
$this->model->update(true, [], false);
$this->assertFalse($this->model->can_export_configuration());
$statictarget = new \test_static_target_shortname();
$indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max');
$model = \core_analytics\model::create($statictarget, $indicators, '\\core\\analytics\\time_splitting\\quarters');
$this->assertFalse($model->can_export_configuration());
}
/**
* Test export_config
*/
public function test_export_config(): void {
$this->resetAfterTest(true);
$this->model->enable('\\core\\analytics\\time_splitting\\quarters');
$modelconfig = new \core_analytics\model_config($this->model);
$method = new \ReflectionMethod('\\core_analytics\\model_config', 'export_model_data');
$modeldata = $method->invoke($modelconfig);
$this->assertArrayHasKey('core', $modeldata->dependencies);
$this->assertIsFloat($modeldata->dependencies['core']);
$this->assertNotEmpty($modeldata->target);
$this->assertNotEmpty($modeldata->timesplitting);
$this->assertCount(3, $modeldata->indicators);
$indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max');
$this->model->update(true, $indicators, false);
$modeldata = $method->invoke($modelconfig);
$this->assertCount(1, $modeldata->indicators);
}
/**
* Test the implementation of {@link \core_analytics\model::inplace_editable_name()}.
*/
public function test_inplace_editable_name(): void {
global $PAGE;
$this->resetAfterTest();
$output = new \core_renderer($PAGE, RENDERER_TARGET_GENERAL);
// Check as a user with permission to edit the name.
$this->setAdminUser();
$ie = $this->model->inplace_editable_name();
$this->assertInstanceOf(\core\output\inplace_editable::class, $ie);
$data = $ie->export_for_template($output);
$this->assertEquals('core_analytics', $data['component']);
$this->assertEquals('modelname', $data['itemtype']);
// Check as a user without permission to edit the name.
$this->setGuestUser();
$ie = $this->model->inplace_editable_name();
$this->assertInstanceOf(\core\output\inplace_editable::class, $ie);
$data = $ie->export_for_template($output);
$this->assertArrayHasKey('displayvalue', $data);
}
/**
* Test how the models present themselves in the UI and that they can be renamed.
*/
public function test_get_name_and_rename(): void {
global $PAGE;
$this->resetAfterTest();
$output = new \core_renderer($PAGE, RENDERER_TARGET_GENERAL);
// By default, the model exported for template uses its target's name in the name inplace editable element.
$this->assertEquals($this->model->get_name(), $this->model->get_target()->get_name());
$data = $this->model->export($output);
$this->assertEquals($data->name['displayvalue'], $this->model->get_target()->get_name());
$this->assertEquals($data->name['value'], '');
// Rename the model.
$this->model->rename('Nějaký pokusný model');
$this->assertEquals($this->model->get_name(), 'Nějaký pokusný model');
$data = $this->model->export($output);
$this->assertEquals($data->name['displayvalue'], 'Nějaký pokusný model');
$this->assertEquals($data->name['value'], 'Nějaký pokusný model');
// Undo the renaming.
$this->model->rename('');
$this->assertEquals($this->model->get_name(), $this->model->get_target()->get_name());
$data = $this->model->export($output);
$this->assertEquals($data->name['displayvalue'], $this->model->get_target()->get_name());
$this->assertEquals($data->name['value'], '');
}
/**
* Tests model::get_potential_timesplittings()
*/
public function test_potential_timesplittings(): void {
$this->resetAfterTest();
$this->assertArrayNotHasKey('\core\analytics\time_splitting\no_splitting', $this->model->get_potential_timesplittings());
$this->assertArrayHasKey('\core\analytics\time_splitting\single_range', $this->model->get_potential_timesplittings());
$this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $this->model->get_potential_timesplittings());
}
/**
* Tests model::get_samples()
*
* @return null
*/
public function test_get_samples(): void {
$this->resetAfterTest();
if (!PHPUNIT_LONGTEST) {
$this->markTestSkipped('PHPUNIT_LONGTEST is not defined');
}
// 10000 should be enough to make oracle and mssql fail, if we want pgsql to fail we need around 70000
// users, that is a few minutes just to create the users.
$nusers = 10000;
$userids = [];
for ($i = 0; $i < $nusers; $i++) {
$user = $this->getDataGenerator()->create_user();
$userids[] = $user->id;
}
$upcomingactivities = null;
foreach (\core_analytics\manager::get_all_models() as $model) {
if (get_class($model->get_target()) === 'core_user\\analytics\\target\\upcoming_activities_due') {
$upcomingactivities = $model;
}
}
list($sampleids, $samplesdata) = $upcomingactivities->get_samples($userids);
$this->assertCount($nusers, $sampleids);
$this->assertCount($nusers, $samplesdata);
$subset = array_slice($userids, 0, 100);
list($sampleids, $samplesdata) = $upcomingactivities->get_samples($subset);
$this->assertCount(100, $sampleids);
$this->assertCount(100, $samplesdata);
$subset = array_slice($userids, 0, 2);
list($sampleids, $samplesdata) = $upcomingactivities->get_samples($subset);
$this->assertCount(2, $sampleids);
$this->assertCount(2, $samplesdata);
$subset = array_slice($userids, 0, 1);
list($sampleids, $samplesdata) = $upcomingactivities->get_samples($subset);
$this->assertCount(1, $sampleids);
$this->assertCount(1, $samplesdata);
// Unexisting, so nothing returned, but still 2 arrays.
list($sampleids, $samplesdata) = $upcomingactivities->get_samples([1231231231231231]);
$this->assertEmpty($sampleids);
$this->assertEmpty($samplesdata);
}
/**
* Generates a model log record.
*/
private function add_fake_log() {
global $DB, $USER;
$log = new \stdClass();
$log->modelid = $this->modelobj->id;
$log->version = $this->modelobj->version;
$log->target = $this->modelobj->target;
$log->indicators = $this->modelobj->indicators;
$log->score = 1;
$log->info = json_encode([]);
$log->dir = 'not important';
$log->timecreated = time();
$log->usermodified = $USER->id;
$DB->insert_record('analytics_models_log', $log);
}
}
/**
* Testable version to change methods' visibility.
*
* @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 testable_model extends \core_analytics\model {
/**
* init_analyser
*
* @param array $options
* @return void
*/
public function init_analyser($options = array()) {
parent::init_analyser($options);
}
}
+265
View File
@@ -0,0 +1,265 @@
<?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/>.
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/fixtures/test_indicator_max.php');
require_once(__DIR__ . '/fixtures/test_target_shortname.php');
/**
* Unit tests for prediction actions.
*
* @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 prediction_actions_test extends \advanced_testcase {
/** @var model Store Model. */
protected $model;
/** @var \stdClass Store model object. */
protected $modelobj;
/** @var \stdClass Course 1 record. */
protected $course1;
/** @var \stdClass Course 2 record. */
protected $course2;
/** @var \context_course Store Model. */
protected $context;
/** @var \stdClass Teacher 1 user record. */
protected $teacher1;
/** @var \stdClass Teacher 2 user record. */
protected $teacher2;
/** @var \stdClass Teacher 3 user record. */
protected $teacher3;
/**
* Common startup tasks
*/
public function setUp(): void {
global $DB;
$this->setAdminUser();
$target = \core_analytics\manager::get_target('test_target_shortname');
$indicators = array('test_indicator_max');
foreach ($indicators as $key => $indicator) {
$indicators[$key] = \core_analytics\manager::get_indicator($indicator);
}
$this->model = \core_analytics\model::create($target, $indicators);
$this->modelobj = $this->model->get_model_obj();
$this->model->enable('\core\analytics\time_splitting\single_range');
$this->resetAfterTest(true);
$this->course1 = $this->getDataGenerator()->create_course();
$this->course2 = $this->getDataGenerator()->create_course();
$this->context = \context_course::instance($this->course1->id);
$this->teacher1 = $this->getDataGenerator()->create_user();
$this->teacher2 = $this->getDataGenerator()->create_user();
$this->teacher3 = $this->getDataGenerator()->create_user();
$this->getDataGenerator()->enrol_user($this->teacher1->id, $this->course1->id, 'editingteacher');
$this->getDataGenerator()->enrol_user($this->teacher2->id, $this->course1->id, 'editingteacher');
$this->getDataGenerator()->enrol_user($this->teacher3->id, $this->course1->id, 'editingteacher');
// The only relevant fields are modelid, contextid and sampleid. I'm cheating and setting
// contextid as the course context so teachers can access these predictions.
$pred = new \stdClass();
$pred->modelid = $this->model->get_id();
$pred->contextid = $this->context->id;
$pred->sampleid = $this->course1->id;
$pred->rangeindex = 1;
$pred->prediction = 1;
$pred->predictionscore = 1;
$pred->calculations = json_encode(array('test_indicator_max' => 1));
$pred->timecreated = time();
$DB->insert_record('analytics_predictions', $pred);
$pred->sampleid = $this->course2->id;
$DB->insert_record('analytics_predictions', $pred);
}
/**
* test_get_predictions
*/
public function test_action_executed(): void {
global $DB;
$this->assertEquals(0, $DB->count_records('analytics_prediction_actions'));
// Teacher 2 flags a prediction (it doesn't matter which one) as fixed.
$this->setUser($this->teacher2);
list($ignored, $predictions) = $this->model->get_predictions($this->context, true);
$prediction = reset($predictions);
$prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target());
$recordset = $this->model->get_prediction_actions($this->context);
$this->assertCount(1, $recordset);
$recordset->close();
$this->assertEquals(1, $DB->count_records('analytics_prediction_actions'));
$action = $DB->get_record('analytics_prediction_actions', array('userid' => $this->teacher2->id));
$this->assertEquals(\core_analytics\prediction::ACTION_FIXED, $action->actionname);
$prediction->action_executed(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $this->model->get_target());
$recordset = $this->model->get_prediction_actions($this->context);
$this->assertCount(2, $recordset);
$recordset->close();
$this->assertEquals(2, $DB->count_records('analytics_prediction_actions'));
}
/**
* Data provider for test_get_executed_actions.
*
* @return array
*/
public function execute_actions_provider(): array {
return [
'Empty actions with no filter' => [
[],
[],
0
],
'Empty actions with filter' => [
[],
[\core_analytics\prediction::ACTION_FIXED],
0
],
'Multiple actions with no filter' => [
[
\core_analytics\prediction::ACTION_FIXED,
\core_analytics\prediction::ACTION_FIXED,
\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED
],
[],
3
],
'Multiple actions applying filter' => [
[
\core_analytics\prediction::ACTION_FIXED,
\core_analytics\prediction::ACTION_FIXED,
\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED
],
[\core_analytics\prediction::ACTION_FIXED],
2
],
'Multiple actions not applying filter' => [
[
\core_analytics\prediction::ACTION_FIXED,
\core_analytics\prediction::ACTION_FIXED,
\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED
],
[\core_analytics\prediction::ACTION_NOT_APPLICABLE],
0
],
'Multiple actions with multiple filter' => [
[
\core_analytics\prediction::ACTION_FIXED,
\core_analytics\prediction::ACTION_FIXED,
\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED
],
[\core_analytics\prediction::ACTION_FIXED, \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED],
3
],
];
}
/**
* Tests for get_executed_actions() function.
*
* @dataProvider execute_actions_provider
* @param array $actionstoexecute An array of actions to execute
* @param array $actionnamefilter Actions to filter
* @param int $returned Number of actions returned
*
* @covers \core_analytics\prediction::get_executed_actions
*/
public function test_get_executed_actions(array $actionstoexecute, array $actionnamefilter, int $returned): void {
$this->setUser($this->teacher2);
list($ignored, $predictions) = $this->model->get_predictions($this->context, true);
$prediction = reset($predictions);
$target = $this->model->get_target();
foreach($actionstoexecute as $action) {
$prediction->action_executed($action, $target);
}
$filteredactions = $prediction->get_executed_actions($actionnamefilter);
$this->assertCount($returned, $filteredactions);
}
/**
* test_get_predictions
*/
public function test_get_predictions(): void {
global $DB;
// Already logged in as admin.
list($ignored, $predictions) = $this->model->get_predictions($this->context, true);
$this->assertCount(2, $predictions);
$this->setUser($this->teacher1);
list($ignored, $predictions) = $this->model->get_predictions($this->context, true);
$this->assertCount(2, $predictions);
$this->setUser($this->teacher2);
list($ignored, $predictions) = $this->model->get_predictions($this->context, false);
$this->assertCount(2, $predictions);
// Teacher 2 flags a prediction (it doesn't matter which one).
$prediction = reset($predictions);
$prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target());
$prediction->action_executed(\core_analytics\prediction::ACTION_NOT_APPLICABLE, $this->model->get_target());
$prediction->action_executed(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $this->model->get_target());
$recordset = $this->model->get_prediction_actions($this->context);
$this->assertCount(3, $recordset);
$recordset->close();
list($ignored, $predictions) = $this->model->get_predictions($this->context, true);
$this->assertCount(1, $predictions);
list($ignored, $predictions) = $this->model->get_predictions($this->context, false);
$this->assertCount(2, $predictions);
// Teacher 1 can still see both predictions.
$this->setUser($this->teacher1);
list($ignored, $predictions) = $this->model->get_predictions($this->context, true);
$this->assertCount(2, $predictions);
list($ignored, $predictions) = $this->model->get_predictions($this->context, false);
$this->assertCount(2, $predictions);
$recordset = $this->model->get_prediction_actions($this->context);
$this->assertCount(3, $recordset);
$recordset->close();
// Trying with a deleted course.
$DB->delete_records('course', ['id' => $this->course2->id]);
$this->setUser($this->teacher3);
list($ignored, $predictions) = $this->model->get_predictions($this->context);
$this->assertCount(1, $predictions);
reset($predictions)->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target());
$this->assertEmpty($this->model->get_predictions($this->context));
}
}
+990
View File
@@ -0,0 +1,990 @@
<?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/>.
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/fixtures/test_indicator_max.php');
require_once(__DIR__ . '/fixtures/test_indicator_min.php');
require_once(__DIR__ . '/fixtures/test_indicator_null.php');
require_once(__DIR__ . '/fixtures/test_indicator_fullname.php');
require_once(__DIR__ . '/fixtures/test_indicator_random.php');
require_once(__DIR__ . '/fixtures/test_indicator_multiclass.php');
require_once(__DIR__ . '/fixtures/test_target_shortname.php');
require_once(__DIR__ . '/fixtures/test_target_shortname_multiclass.php');
require_once(__DIR__ . '/fixtures/test_static_target_shortname.php');
require_once(__DIR__ . '/../../course/lib.php');
/**
* Unit tests for evaluation, training and prediction.
*
* NOTE: in order to execute this test using a separate server for the
* python ML backend you need to define these variables in your config.php file:
*
* define('TEST_MLBACKEND_PYTHON_HOST', '127.0.0.1');
* define('TEST_MLBACKEND_PYTHON_PORT', 5000);
* define('TEST_MLBACKEND_PYTHON_USERNAME', 'default');
* define('TEST_MLBACKEND_PYTHON_PASSWORD', 'sshhhh');
*
* @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 prediction_test extends \advanced_testcase {
/**
* Purge all the mlbackend outputs.
*
* This is done automatically for mlbackends using the web server dataroot but
* other mlbackends may store files elsewhere and these files need to be removed.
*
* @return null
*/
public function tearDown(): void {
$this->setAdminUser();
$models = \core_analytics\manager::get_all_models();
foreach ($models as $model) {
$model->delete();
}
}
/**
* test_static_prediction
*
* @return void
*/
public function test_static_prediction(): void {
global $DB;
$this->resetAfterTest(true);
$this->setAdminuser();
$model = $this->add_perfect_model('test_static_target_shortname');
$model->enable('\core\analytics\time_splitting\no_splitting');
$this->assertEquals(1, $model->is_enabled());
$this->assertEquals(1, $model->is_trained());
// No training for static models.
$results = $model->train();
$trainedsamples = $DB->get_records('analytics_train_samples', array('modelid' => $model->get_id()));
$this->assertEmpty($trainedsamples);
$this->assertEmpty($DB->count_records('analytics_used_files',
array('modelid' => $model->get_id(), 'action' => 'trained')));
// Now we create 2 hidden courses (only hidden courses are getting predictions).
$courseparams = array('shortname' => 'aaaaaa', 'fullname' => 'aaaaaa', 'visible' => 0);
$course1 = $this->getDataGenerator()->create_course($courseparams);
$courseparams = array('shortname' => 'bbbbbb', 'fullname' => 'bbbbbb', 'visible' => 0);
$course2 = $this->getDataGenerator()->create_course($courseparams);
$result = $model->predict();
// Var $course1 predictions should be 1 == 'a', $course2 predictions should be 0 == 'b'.
$correct = array($course1->id => 1, $course2->id => 0);
foreach ($result->predictions as $uniquesampleid => $predictiondata) {
list($sampleid, $rangeindex) = $model->get_time_splitting()->infer_sample_info($uniquesampleid);
// The range index is not important here, both ranges prediction will be the same.
$this->assertEquals($correct[$sampleid], $predictiondata->prediction);
}
// 1 range for each analysable.
$predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id()));
$this->assertCount(2, $predictedranges);
// 2 predictions for each range.
$this->assertEquals(2, $DB->count_records('analytics_predictions',
array('modelid' => $model->get_id())));
// No new generated records as there are no new courses available.
$model->predict();
$predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id()));
$this->assertCount(2, $predictedranges);
$this->assertEquals(2, $DB->count_records('analytics_predictions',
array('modelid' => $model->get_id())));
}
/**
* test_model_contexts
*/
public function test_model_contexts(): void {
global $DB;
$this->resetAfterTest(true);
$this->setAdminuser();
$misc = $DB->get_record('course_categories', ['name' => get_string('defaultcategoryname')]);
$miscctx = \context_coursecat::instance($misc->id);
$category = $this->getDataGenerator()->create_category();
$categoryctx = \context_coursecat::instance($category->id);
// One course per category.
$courseparams = array('shortname' => 'aaaaaa', 'fullname' => 'aaaaaa', 'visible' => 0,
'category' => $category->id);
$course1 = $this->getDataGenerator()->create_course($courseparams);
$course1ctx = \context_course::instance($course1->id);
$courseparams = array('shortname' => 'bbbbbb', 'fullname' => 'bbbbbb', 'visible' => 0,
'category' => $misc->id);
$course2 = $this->getDataGenerator()->create_course($courseparams);
$model = $this->add_perfect_model('test_static_target_shortname');
// Just 1 category.
$model->update(true, false, '\core\analytics\time_splitting\no_splitting', false, [$categoryctx->id]);
$this->assertCount(1, $model->predict()->predictions);
// Now with 2 categories.
$model->update(true, false, false, false, [$categoryctx->id, $miscctx->id]);
// The courses in the new category are processed.
$this->assertCount(1, $model->predict()->predictions);
// Clear the predictions generated by the model and predict() again.
$model->clear();
$this->assertCount(2, $model->predict()->predictions);
// Course context restriction.
$model->update(true, false, '\core\analytics\time_splitting\no_splitting', false, [$course1ctx->id]);
// Nothing new as the course was already analysed.
$result = $model->predict();
$this->assertTrue(empty($result->predictions));
$model->clear();
$this->assertCount(1, $model->predict()->predictions);
}
/**
* test_ml_training_and_prediction
*
* @dataProvider provider_ml_training_and_prediction
* @param string $timesplittingid
* @param int $predictedrangeindex
* @param int $nranges
* @param string $predictionsprocessorclass
* @param array $forcedconfig
* @return void
*/
public function test_ml_training_and_prediction($timesplittingid, $predictedrangeindex, $nranges, $predictionsprocessorclass,
$forcedconfig): void {
global $DB;
$this->resetAfterTest(true);
$this->set_forced_config($forcedconfig);
$predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass);
$this->setAdminuser();
set_config('enabled_stores', 'logstore_standard', 'tool_log');
// Generate training data.
$ncourses = 10;
$this->generate_courses($ncourses);
$model = $this->add_perfect_model();
$model->update(true, false, $timesplittingid, get_class($predictionsprocessor));
// No samples trained yet.
$this->assertEquals(0, $DB->count_records('analytics_train_samples', array('modelid' => $model->get_id())));
$results = $model->train();
$this->assertEquals(1, $model->is_enabled());
$this->assertEquals(1, $model->is_trained());
// 20 courses * the 3 model indicators * the number of time ranges of this time splitting method.
$indicatorcalc = 20 * 3 * $nranges;
$this->assertEquals($indicatorcalc, $DB->count_records('analytics_indicator_calc'));
// 1 training file was created.
$trainedsamples = $DB->get_records('analytics_train_samples', array('modelid' => $model->get_id()));
$this->assertCount(1, $trainedsamples);
$samples = json_decode(reset($trainedsamples)->sampleids, true);
$this->assertCount($ncourses * 2, $samples);
$this->assertEquals(1, $DB->count_records('analytics_used_files',
array('modelid' => $model->get_id(), 'action' => 'trained')));
// Check that analysable files for training are stored under labelled filearea.
$fs = get_file_storage();
$this->assertCount(1, $fs->get_directory_files(\context_system::instance()->id, 'analytics',
\core_analytics\dataset_manager::LABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false));
$this->assertEmpty($fs->get_directory_files(\context_system::instance()->id, 'analytics',
\core_analytics\dataset_manager::UNLABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false));
$params = [
'startdate' => mktime(0, 0, 0, 10, 24, 2015),
'enddate' => mktime(0, 0, 0, 2, 24, 2016),
];
$courseparams = $params + array('shortname' => 'aaaaaa', 'fullname' => 'aaaaaa', 'visible' => 0);
$course1 = $this->getDataGenerator()->create_course($courseparams);
$courseparams = $params + array('shortname' => 'bbbbbb', 'fullname' => 'bbbbbb', 'visible' => 0);
$course2 = $this->getDataGenerator()->create_course($courseparams);
// They will not be skipped for prediction though.
$result = $model->predict();
// Var $course1 predictions should be 1 == 'a', $course2 predictions should be 0 == 'b'.
$correct = array($course1->id => 1, $course2->id => 0);
foreach ($result->predictions as $uniquesampleid => $predictiondata) {
list($sampleid, $rangeindex) = $model->get_time_splitting()->infer_sample_info($uniquesampleid);
// The range index is not important here, both ranges prediction will be the same.
$this->assertEquals($correct[$sampleid], $predictiondata->prediction);
}
// 1 range will be predicted.
$predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id()));
$this->assertCount(1, $predictedranges);
foreach ($predictedranges as $predictedrange) {
$this->assertEquals($predictedrangeindex, $predictedrange->rangeindex);
$sampleids = json_decode($predictedrange->sampleids, true);
$this->assertCount(2, $sampleids);
$this->assertContainsEquals($course1->id, $sampleids);
$this->assertContainsEquals($course2->id, $sampleids);
}
$this->assertEquals(1, $DB->count_records('analytics_used_files',
array('modelid' => $model->get_id(), 'action' => 'predicted')));
// 2 predictions.
$this->assertEquals(2, $DB->count_records('analytics_predictions',
array('modelid' => $model->get_id())));
// Check that analysable files to get predictions are stored under unlabelled filearea.
$this->assertCount(1, $fs->get_directory_files(\context_system::instance()->id, 'analytics',
\core_analytics\dataset_manager::LABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false));
$this->assertCount(1, $fs->get_directory_files(\context_system::instance()->id, 'analytics',
\core_analytics\dataset_manager::UNLABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false));
// No new generated files nor records as there are no new courses available.
$model->predict();
$predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id()));
$this->assertCount(1, $predictedranges);
foreach ($predictedranges as $predictedrange) {
$this->assertEquals($predictedrangeindex, $predictedrange->rangeindex);
}
$this->assertEquals(1, $DB->count_records('analytics_used_files',
array('modelid' => $model->get_id(), 'action' => 'predicted')));
$this->assertEquals(2, $DB->count_records('analytics_predictions',
array('modelid' => $model->get_id())));
// New samples that can be used for prediction.
$courseparams = $params + array('shortname' => 'cccccc', 'fullname' => 'cccccc', 'visible' => 0);
$course3 = $this->getDataGenerator()->create_course($courseparams);
$courseparams = $params + array('shortname' => 'dddddd', 'fullname' => 'dddddd', 'visible' => 0);
$course4 = $this->getDataGenerator()->create_course($courseparams);
$result = $model->predict();
$predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id()));
$this->assertCount(1, $predictedranges);
foreach ($predictedranges as $predictedrange) {
$this->assertEquals($predictedrangeindex, $predictedrange->rangeindex);
$sampleids = json_decode($predictedrange->sampleids, true);
$this->assertCount(4, $sampleids);
$this->assertContainsEquals($course1->id, $sampleids);
$this->assertContainsEquals($course2->id, $sampleids);
$this->assertContainsEquals($course3->id, $sampleids);
$this->assertContainsEquals($course4->id, $sampleids);
}
$this->assertEquals(2, $DB->count_records('analytics_used_files',
array('modelid' => $model->get_id(), 'action' => 'predicted')));
$this->assertEquals(4, $DB->count_records('analytics_predictions',
array('modelid' => $model->get_id())));
$this->assertCount(1, $fs->get_directory_files(\context_system::instance()->id, 'analytics',
\core_analytics\dataset_manager::LABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false));
$this->assertCount(2, $fs->get_directory_files(\context_system::instance()->id, 'analytics',
\core_analytics\dataset_manager::UNLABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false));
// New visible course (for training).
$course5 = $this->getDataGenerator()->create_course(array('shortname' => 'aaa', 'fullname' => 'aa'));
$course6 = $this->getDataGenerator()->create_course();
$result = $model->train();
$this->assertEquals(2, $DB->count_records('analytics_used_files',
array('modelid' => $model->get_id(), 'action' => 'trained')));
$this->assertCount(2, $fs->get_directory_files(\context_system::instance()->id, 'analytics',
\core_analytics\dataset_manager::LABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false));
$this->assertCount(2, $fs->get_directory_files(\context_system::instance()->id, 'analytics',
\core_analytics\dataset_manager::UNLABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false));
// Confirm that the files associated to the model are deleted on clear and on delete. The ML backend deletion
// processes will be triggered by these actions and any exception there would result in a failed test.
$model->clear();
$this->assertEquals(0, $DB->count_records('analytics_used_files',
array('modelid' => $model->get_id(), 'action' => 'trained')));
$this->assertCount(0, $fs->get_directory_files(\context_system::instance()->id, 'analytics',
\core_analytics\dataset_manager::LABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false));
$this->assertCount(0, $fs->get_directory_files(\context_system::instance()->id, 'analytics',
\core_analytics\dataset_manager::UNLABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false));
$model->delete();
set_config('enabled_stores', '', 'tool_log');
get_log_manager(true);
}
/**
* provider_ml_training_and_prediction
*
* @return array
*/
public function provider_ml_training_and_prediction() {
$cases = array(
'no_splitting' => array('\core\analytics\time_splitting\no_splitting', 0, 1),
'quarters' => array('\core\analytics\time_splitting\quarters', 3, 4)
);
// We need to test all system prediction processors.
return $this->add_prediction_processors($cases);
}
/**
* test_ml_export_import
*
* @param string $predictionsprocessorclass The class name
* @param array $forcedconfig
* @dataProvider provider_ml_processors
*/
public function test_ml_export_import($predictionsprocessorclass, $forcedconfig): void {
$this->resetAfterTest(true);
$this->set_forced_config($forcedconfig);
$predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass);
$this->setAdminuser();
set_config('enabled_stores', 'logstore_standard', 'tool_log');
// Generate training data.
$ncourses = 10;
$this->generate_courses($ncourses);
$model = $this->add_perfect_model();
$model->update(true, false, '\core\analytics\time_splitting\quarters', get_class($predictionsprocessor));
$model->train();
$this->assertTrue($model->trained_locally());
$this->generate_courses(10, ['visible' => 0]);
$originalresults = $model->predict();
$zipfilename = 'model-zip-' . microtime() . '.zip';
$zipfilepath = $model->export_model($zipfilename);
$modelconfig = new \core_analytics\model_config();
list($modelconfig, $mlbackend) = $modelconfig->extract_import_contents($zipfilepath);
$this->assertNotFalse($mlbackend);
$importmodel = \core_analytics\model::import_model($zipfilepath);
$importmodel->enable();
// Now predict using the imported model without prior training.
$importedmodelresults = $importmodel->predict();
foreach ($originalresults->predictions as $sampleid => $prediction) {
$this->assertEquals($importedmodelresults->predictions[$sampleid]->prediction, $prediction->prediction);
}
$this->assertFalse($importmodel->trained_locally());
$zipfilename = 'model-zip-' . microtime() . '.zip';
$zipfilepath = $model->export_model($zipfilename, false);
$modelconfig = new \core_analytics\model_config();
list($modelconfig, $mlbackend) = $modelconfig->extract_import_contents($zipfilepath);
$this->assertFalse($mlbackend);
set_config('enabled_stores', '', 'tool_log');
get_log_manager(true);
}
/**
* provider_ml_processors
*
* @return array
*/
public function provider_ml_processors() {
$cases = [
'case' => [],
];
// We need to test all system prediction processors.
return $this->add_prediction_processors($cases);
}
/**
* Test the system classifiers returns.
*
* This test checks that all mlbackend plugins in the system are able to return proper status codes
* even under weird situations.
*
* @dataProvider provider_ml_classifiers_return
* @param int $success
* @param int $nsamples
* @param int $classes
* @param string $predictionsprocessorclass
* @param array $forcedconfig
* @return void
*/
public function test_ml_classifiers_return($success, $nsamples, $classes, $predictionsprocessorclass, $forcedconfig): void {
$this->resetAfterTest();
$this->set_forced_config($forcedconfig);
$predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass);
if ($nsamples % count($classes) != 0) {
throw new \coding_exception('The number of samples should be divisible by the number of classes');
}
$samplesperclass = $nsamples / count($classes);
// Metadata (we pass 2 classes even if $classes only provides 1 class samples as we want to test
// what the backend does in this case.
$dataset = "nfeatures,targetclasses,targettype" . PHP_EOL;
$dataset .= "3,\"[0,1]\",\"discrete\"" . PHP_EOL;
// Headers.
$dataset .= "feature1,feature2,feature3,target" . PHP_EOL;
foreach ($classes as $class) {
for ($i = 0; $i < $samplesperclass; $i++) {
$dataset .= "1,0,1,$class" . PHP_EOL;
}
}
$trainingfile = array(
'contextid' => \context_system::instance()->id,
'component' => 'analytics',
'filearea' => 'labelled',
'itemid' => 123,
'filepath' => '/',
'filename' => 'whocares.csv'
);
$fs = get_file_storage();
$dataset = $fs->create_file_from_string($trainingfile, $dataset);
// Training should work correctly if at least 1 sample of each class is included.
$dir = make_request_directory();
$modeluniqueid = 'whatever' . microtime();
$result = $predictionsprocessor->train_classification($modeluniqueid, $dataset, $dir);
switch ($success) {
case 'yes':
$this->assertEquals(\core_analytics\model::OK, $result->status);
break;
case 'no':
$this->assertNotEquals(\core_analytics\model::OK, $result->status);
break;
case 'maybe':
default:
// We just check that an object is returned so we don't have an empty check,
// what we really want to check is that an exception was not thrown.
$this->assertInstanceOf(\stdClass::class, $result);
}
// Purge the directory used in this test (useful in case the mlbackend is storing files
// somewhere out of the default moodledata/models dir.
$predictionsprocessor->delete_output_dir($dir, $modeluniqueid);
}
/**
* test_ml_classifiers_return provider
*
* We can not be very specific here as test_ml_classifiers_return only checks that
* mlbackend plugins behave and expected and control properly backend errors even
* under weird situations.
*
* @return array
*/
public function provider_ml_classifiers_return() {
// Using verbose options as the first argument for readability.
$cases = array(
'1-samples' => array('maybe', 1, [0]),
'2-samples-same-class' => array('maybe', 2, [0]),
'2-samples-different-classes' => array('yes', 2, [0, 1]),
'4-samples-different-classes' => array('yes', 4, [0, 1])
);
// We need to test all system prediction processors.
return $this->add_prediction_processors($cases);
}
/**
* Tests correct multi-classification.
*
* @dataProvider provider_test_multi_classifier
* @param string $timesplittingid
* @param string $predictionsprocessorclass
* @param array|null $forcedconfig
* @throws coding_exception
* @throws moodle_exception
*/
public function test_ml_multi_classifier($timesplittingid, $predictionsprocessorclass, $forcedconfig): void {
global $DB;
$this->resetAfterTest(true);
$this->setAdminuser();
set_config('enabled_stores', 'logstore_standard', 'tool_log');
$this->set_forced_config($forcedconfig);
$predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false);
if ($predictionsprocessor->is_ready() !== true) {
$this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
}
// Generate training courses.
$ncourses = 5;
$this->generate_courses_multiclass($ncourses);
$model = $this->add_multiclass_model();
$model->update(true, false, $timesplittingid, get_class($predictionsprocessor));
$results = $model->train();
$params = [
'startdate' => mktime(0, 0, 0, 10, 24, 2015),
'enddate' => mktime(0, 0, 0, 2, 24, 2016),
];
$courseparams = $params + array('shortname' => 'aaaaaa', 'fullname' => 'aaaaaa', 'visible' => 0);
$course1 = $this->getDataGenerator()->create_course($courseparams);
$courseparams = $params + array('shortname' => 'bbbbbb', 'fullname' => 'bbbbbb', 'visible' => 0);
$course2 = $this->getDataGenerator()->create_course($courseparams);
$courseparams = $params + array('shortname' => 'cccccc', 'fullname' => 'cccccc', 'visible' => 0);
$course3 = $this->getDataGenerator()->create_course($courseparams);
// They will not be skipped for prediction though.
$result = $model->predict();
// The $course1 predictions should be 0 == 'a', $course2 should be 1 == 'b' and $course3 should be 2 == 'c'.
$correct = array($course1->id => 0, $course2->id => 1, $course3->id => 2);
foreach ($result->predictions as $uniquesampleid => $predictiondata) {
list($sampleid, $rangeindex) = $model->get_time_splitting()->infer_sample_info($uniquesampleid);
// The range index is not important here, both ranges prediction will be the same.
$this->assertEquals($correct[$sampleid], $predictiondata->prediction);
}
set_config('enabled_stores', '', 'tool_log');
get_log_manager(true);
}
/**
* Provider for the multi_classification test.
*
* @return array
*/
public function provider_test_multi_classifier() {
$cases = array(
'notimesplitting' => array('\core\analytics\time_splitting\no_splitting'),
);
// Add all system prediction processors.
return $this->add_prediction_processors($cases);
}
/**
* Basic test to check that prediction processors work as expected.
*
* @coversNothing
* @dataProvider provider_ml_test_evaluation_configuration
* @param string $modelquality
* @param int $ncourses
* @param array $expected
* @param string $predictionsprocessorclass
* @param array $forcedconfig
* @return void
*/
public function test_ml_evaluation_configuration($modelquality, $ncourses, $expected, $predictionsprocessorclass,
$forcedconfig): void {
$this->resetAfterTest(true);
$this->set_forced_config($forcedconfig);
$predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass);
$this->setAdminuser();
set_config('enabled_stores', 'logstore_standard', 'tool_log');
$sometimesplittings = '\core\analytics\time_splitting\single_range,' .
'\core\analytics\time_splitting\quarters';
set_config('defaulttimesplittingsevaluation', $sometimesplittings, 'analytics');
if ($modelquality === 'perfect') {
$model = $this->add_perfect_model();
} else if ($modelquality === 'random') {
$model = $this->add_random_model();
} else {
throw new \coding_exception('Only perfect and random accepted as $modelquality values');
}
// Generate training data.
$this->generate_courses($ncourses);
$model->update(false, false, false, get_class($predictionsprocessor));
$results = $model->evaluate();
// We check that the returned status includes at least $expectedcode code.
foreach ($results as $timesplitting => $result) {
$message = 'The returned status code ' . $result->status . ' should include ' . $expected[$timesplitting];
$filtered = $result->status & $expected[$timesplitting];
$this->assertEquals($expected[$timesplitting], $filtered, $message);
$options = ['evaluation' => true, 'reuseprevanalysed' => true];
$result = new \core_analytics\local\analysis\result_file($model->get_id(), true, $options);
$timesplittingobj = \core_analytics\manager::get_time_splitting($timesplitting);
$analysable = new \core_analytics\site();
$cachedanalysis = $result->retrieve_cached_result($timesplittingobj, $analysable);
$this->assertInstanceOf(\stored_file::class, $cachedanalysis);
}
set_config('enabled_stores', '', 'tool_log');
get_log_manager(true);
}
/**
* Tests the evaluation of already trained models.
*
* @coversNothing
* @dataProvider provider_ml_processors
* @param string $predictionsprocessorclass
* @param array $forcedconfig
* @return null
*/
public function test_ml_evaluation_trained_model($predictionsprocessorclass, $forcedconfig): void {
$this->resetAfterTest(true);
$this->set_forced_config($forcedconfig);
$predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass);
$this->setAdminuser();
set_config('enabled_stores', 'logstore_standard', 'tool_log');
$model = $this->add_perfect_model();
// Generate training data.
$this->generate_courses(50);
$model->update(true, false, '\\core\\analytics\\time_splitting\\quarters', get_class($predictionsprocessor));
$model->train();
$zipfilename = 'model-zip-' . microtime() . '.zip';
$zipfilepath = $model->export_model($zipfilename);
$importmodel = \core_analytics\model::import_model($zipfilepath);
$results = $importmodel->evaluate(['mode' => 'trainedmodel']);
$this->assertEquals(0, $results['\\core\\analytics\\time_splitting\\quarters']->status);
$this->assertEquals(1, $results['\\core\\analytics\\time_splitting\\quarters']->score);
set_config('enabled_stores', '', 'tool_log');
get_log_manager(true);
}
/**
* test_read_indicator_calculations
*
* @return void
*/
public function test_read_indicator_calculations(): void {
global $DB;
$this->resetAfterTest(true);
$starttime = 123;
$endtime = 321;
$sampleorigin = 'whatever';
$indicator = $this->getMockBuilder('test_indicator_max')->onlyMethods(['calculate_sample'])->getMock();
$indicator->expects($this->never())->method('calculate_sample');
$existingcalcs = array(111 => 1, 222 => -1);
$sampleids = array(111 => 111, 222 => 222);
list($values, $unused) = $indicator->calculate($sampleids, $sampleorigin, $starttime, $endtime, $existingcalcs);
}
/**
* test_not_null_samples
*/
public function test_not_null_samples(): void {
$this->resetAfterTest(true);
$timesplitting = \core_analytics\manager::get_time_splitting('\core\analytics\time_splitting\quarters');
$timesplitting->set_analysable(new \core_analytics\site());
$ranges = array(
array('start' => 111, 'end' => 222, 'time' => 222),
array('start' => 222, 'end' => 333, 'time' => 333)
);
$samples = array(123 => 123, 321 => 321);
$target = \core_analytics\manager::get_target('test_target_shortname');
$indicators = array('test_indicator_null', 'test_indicator_min');
foreach ($indicators as $key => $indicator) {
$indicators[$key] = \core_analytics\manager::get_indicator($indicator);
}
$model = \core_analytics\model::create($target, $indicators, '\core\analytics\time_splitting\no_splitting');
$analyser = $model->get_analyser();
$result = new \core_analytics\local\analysis\result_array($model->get_id(), false, $analyser->get_options());
$analysis = new \core_analytics\analysis($analyser, false, $result);
// Samples with at least 1 not null value are returned.
$params = array(
$timesplitting,
$samples,
$ranges
);
$dataset = \phpunit_util::call_internal_method($analysis, 'calculate_indicators', $params,
'\core_analytics\analysis');
$this->assertArrayHasKey('123-0', $dataset);
$this->assertArrayHasKey('123-1', $dataset);
$this->assertArrayHasKey('321-0', $dataset);
$this->assertArrayHasKey('321-1', $dataset);
$indicators = array('test_indicator_null');
foreach ($indicators as $key => $indicator) {
$indicators[$key] = \core_analytics\manager::get_indicator($indicator);
}
$model = \core_analytics\model::create($target, $indicators, '\core\analytics\time_splitting\no_splitting');
$analyser = $model->get_analyser();
$result = new \core_analytics\local\analysis\result_array($model->get_id(), false, $analyser->get_options());
$analysis = new \core_analytics\analysis($analyser, false, $result);
// Samples with only null values are not returned.
$params = array(
$timesplitting,
$samples,
$ranges
);
$dataset = \phpunit_util::call_internal_method($analysis, 'calculate_indicators', $params,
'\core_analytics\analysis');
$this->assertArrayNotHasKey('123-0', $dataset);
$this->assertArrayNotHasKey('123-1', $dataset);
$this->assertArrayNotHasKey('321-0', $dataset);
$this->assertArrayNotHasKey('321-1', $dataset);
}
/**
* provider_ml_test_evaluation_configuration
*
* @return array
*/
public function provider_ml_test_evaluation_configuration() {
$cases = array(
'bad' => array(
'modelquality' => 'random',
'ncourses' => 50,
'expectedresults' => array(
'\core\analytics\time_splitting\single_range' => \core_analytics\model::LOW_SCORE,
'\core\analytics\time_splitting\quarters' => \core_analytics\model::LOW_SCORE,
)
),
'good' => array(
'modelquality' => 'perfect',
'ncourses' => 50,
'expectedresults' => array(
'\core\analytics\time_splitting\single_range' => \core_analytics\model::OK,
'\core\analytics\time_splitting\quarters' => \core_analytics\model::OK,
)
)
);
return $this->add_prediction_processors($cases);
}
/**
* add_random_model
*
* @return \core_analytics\model
*/
protected function add_random_model() {
$target = \core_analytics\manager::get_target('test_target_shortname');
$indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_random');
foreach ($indicators as $key => $indicator) {
$indicators[$key] = \core_analytics\manager::get_indicator($indicator);
}
$model = \core_analytics\model::create($target, $indicators);
// To load db defaults as well.
return new \core_analytics\model($model->get_id());
}
/**
* add_perfect_model
*
* @param string $targetclass
* @return \core_analytics\model
*/
protected function add_perfect_model($targetclass = 'test_target_shortname') {
$target = \core_analytics\manager::get_target($targetclass);
$indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_fullname');
foreach ($indicators as $key => $indicator) {
$indicators[$key] = \core_analytics\manager::get_indicator($indicator);
}
$model = \core_analytics\model::create($target, $indicators);
// To load db defaults as well.
return new \core_analytics\model($model->get_id());
}
/**
* Generates model for multi-classification
*
* @param string $targetclass
* @return \core_analytics\model
* @throws coding_exception
* @throws moodle_exception
*/
public function add_multiclass_model($targetclass = 'test_target_shortname_multiclass') {
$target = \core_analytics\manager::get_target($targetclass);
$indicators = array('test_indicator_fullname', 'test_indicator_multiclass');
foreach ($indicators as $key => $indicator) {
$indicators[$key] = \core_analytics\manager::get_indicator($indicator);
}
$model = \core_analytics\model::create($target, $indicators);
return new \core_analytics\model($model->get_id());
}
/**
* Generates $ncourses courses
*
* @param int $ncourses The number of courses to be generated.
* @param array $params Course params
* @return null
*/
protected function generate_courses($ncourses, array $params = []) {
$params = $params + [
'startdate' => mktime(0, 0, 0, 10, 24, 2015),
'enddate' => mktime(0, 0, 0, 2, 24, 2016),
];
for ($i = 0; $i < $ncourses; $i++) {
$name = 'a' . random_string(10);
$courseparams = array('shortname' => $name, 'fullname' => $name) + $params;
$this->getDataGenerator()->create_course($courseparams);
}
for ($i = 0; $i < $ncourses; $i++) {
$name = 'b' . random_string(10);
$courseparams = array('shortname' => $name, 'fullname' => $name) + $params;
$this->getDataGenerator()->create_course($courseparams);
}
}
/**
* Generates ncourses for multi-classification
*
* @param int $ncourses The number of courses to be generated.
* @param array $params Course params
* @return null
*/
protected function generate_courses_multiclass($ncourses, array $params = []) {
$params = $params + [
'startdate' => mktime(0, 0, 0, 10, 24, 2015),
'enddate' => mktime(0, 0, 0, 2, 24, 2016),
];
for ($i = 0; $i < $ncourses; $i++) {
$name = 'a' . random_string(10);
$courseparams = array('shortname' => $name, 'fullname' => $name) + $params;
$this->getDataGenerator()->create_course($courseparams);
}
for ($i = 0; $i < $ncourses; $i++) {
$name = 'b' . random_string(10);
$courseparams = array('shortname' => $name, 'fullname' => $name) + $params;
$this->getDataGenerator()->create_course($courseparams);
}
for ($i = 0; $i < $ncourses; $i++) {
$name = 'c' . random_string(10);
$courseparams = array('shortname' => $name, 'fullname' => $name) + $params;
$this->getDataGenerator()->create_course($courseparams);
}
}
/**
* Forces some configuration values.
*
* @param array $forcedconfig
*/
protected function set_forced_config($forcedconfig) {
\core_analytics\manager::reset_prediction_processors();
if (empty($forcedconfig)) {
return;
}
foreach ($forcedconfig as $pluginname => $pluginconfig) {
foreach ($pluginconfig as $name => $value) {
set_config($name, $value, $pluginname);
}
}
}
/**
* Is the provided processor ready using the current configuration in the site?
*
* @param string $predictionsprocessorclass
* @return \core_analytics\predictor
*/
protected function is_predictions_processor_ready(string $predictionsprocessorclass) {
// We repeat the test for all prediction processors.
$predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false);
$ready = $predictionsprocessor->is_ready();
if ($ready !== true) {
$this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready: ' . $ready);
}
return $predictionsprocessor;
}
/**
* add_prediction_processors
*
* @param array $cases
* @return array
*/
protected function add_prediction_processors($cases) {
$return = array();
if (defined('TEST_MLBACKEND_PYTHON_HOST') && defined('TEST_MLBACKEND_PYTHON_PORT')
&& defined('TEST_MLBACKEND_PYTHON_USERNAME') && defined('TEST_MLBACKEND_PYTHON_USERNAME')) {
$testpythonserver = true;
}
// We need to test all prediction processors in the system.
$predictionprocessors = \core_analytics\manager::get_all_prediction_processors();
foreach ($predictionprocessors as $classfullname => $predictionsprocessor) {
foreach ($cases as $key => $case) {
if (!$predictionsprocessor instanceof \mlbackend_python\processor || empty($testpythonserver)) {
$extraparams = ['predictionsprocessor' => $classfullname, 'forcedconfig' => null];
$return[$key . '-' . $classfullname] = $case + $extraparams;
} else {
// We want the configuration to be forced during the test as things like importing models create new
// instances of ML backend processors during the process.
$forcedconfig = ['mlbackend_python' => ['useserver' => true, 'host' => TEST_MLBACKEND_PYTHON_HOST,
'port' => TEST_MLBACKEND_PYTHON_PORT, 'secure' => false, 'username' => TEST_MLBACKEND_PYTHON_USERNAME,
'password' => TEST_MLBACKEND_PYTHON_PASSWORD]];
$casekey = $key . '-' . $classfullname . '-server';
$return[$casekey] = $case + ['predictionsprocessor' => $classfullname, 'forcedconfig' => $forcedconfig];
}
}
}
return $return;
}
}
+460
View File
@@ -0,0 +1,460 @@
<?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 privacy.
*
* @package core_analytics
* @copyright 2018 David Monllaó {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\privacy;
use core_analytics\privacy\provider;
use core_privacy\local\request\transform;
use core_privacy\local\request\writer;
use core_privacy\local\request\approved_contextlist;
use core_privacy\local\request\approved_userlist;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/../fixtures/test_indicator_max.php');
require_once(__DIR__ . '/../fixtures/test_indicator_min.php');
require_once(__DIR__ . '/../fixtures/test_target_site_users.php');
require_once(__DIR__ . '/../fixtures/test_target_course_users.php');
/**
* Unit tests for privacy.
*
* @package core_analytics
* @copyright 2018 David Monllaó {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider_test extends \core_privacy\tests\provider_testcase {
/** @var \core_analytics\model Store Model 1. */
protected $model1;
/** @var \core_analytics\model Store Model 2. */
protected $model2;
/** @var \stdClass $modelobj1 Store Model 1 object. */
protected $modelobj1;
/** @var \stdClass $modelobj2 Store Model 2 object. */
protected $modelobj2;
/** @var \stdClass $u1 User 1 record. */
protected $u1;
/** @var \stdClass $u2 User 2 record. */
protected $u2;
/** @var \stdClass $u3 User 3 record. */
protected $u3;
/** @var \stdClass $u4 User 4 record. */
protected $u4;
/** @var \stdClass $u5 User 5 record. */
protected $u5;
/** @var \stdClass $u6 User 6 record. */
protected $u6;
/** @var \stdClass $u7 User 7 record. */
protected $u7;
/** @var \stdClass $u8 User 8 record. */
protected $u8;
/** @var \stdClass $c1 Course 1 record. */
protected $c1;
/** @var \stdClass $c2 Course 2 record. */
protected $c2;
public function setUp(): void {
$this->resetAfterTest(true);
$this->setAdminUser();
$timesplittingid = '\core\analytics\time_splitting\single_range';
$target = \core_analytics\manager::get_target('test_target_site_users');
$indicators = array('test_indicator_max');
foreach ($indicators as $key => $indicator) {
$indicators[$key] = \core_analytics\manager::get_indicator($indicator);
}
$this->model1 = \core_analytics\model::create($target, $indicators, $timesplittingid);
$this->modelobj1 = $this->model1->get_model_obj();
$target = \core_analytics\manager::get_target('test_target_course_users');
$indicators = array('test_indicator_min');
foreach ($indicators as $key => $indicator) {
$indicators[$key] = \core_analytics\manager::get_indicator($indicator);
}
$this->model2 = \core_analytics\model::create($target, $indicators, $timesplittingid);
$this->modelobj2 = $this->model1->get_model_obj();
$this->u1 = $this->getDataGenerator()->create_user(['firstname' => 'a111111111111', 'lastname' => 'a']);
$this->u2 = $this->getDataGenerator()->create_user(['firstname' => 'a222222222222', 'lastname' => 'a']);
$this->u3 = $this->getDataGenerator()->create_user(['firstname' => 'b333333333333', 'lastname' => 'b']);
$this->u4 = $this->getDataGenerator()->create_user(['firstname' => 'b444444444444', 'lastname' => 'b']);
$this->u5 = $this->getdatagenerator()->create_user(['firstname' => 'a555555555555', 'lastname' => 'a']);
$this->u6 = $this->getdatagenerator()->create_user(['firstname' => 'a666666666666', 'lastname' => 'a']);
$this->u7 = $this->getdatagenerator()->create_user(['firstname' => 'b777777777777', 'lastname' => 'b']);
$this->u8 = $this->getDataGenerator()->create_user(['firstname' => 'b888888888888', 'lastname' => 'b']);
$this->c1 = $this->getDataGenerator()->create_course(['visible' => false]);
$this->c2 = $this->getDataGenerator()->create_course();
$this->getDataGenerator()->enrol_user($this->u1->id, $this->c1->id, 'student');
$this->getDataGenerator()->enrol_user($this->u2->id, $this->c1->id, 'student');
$this->getDataGenerator()->enrol_user($this->u3->id, $this->c1->id, 'student');
$this->getDataGenerator()->enrol_user($this->u4->id, $this->c1->id, 'student');
$this->getDataGenerator()->enrol_user($this->u5->id, $this->c1->id, 'student');
$this->getDataGenerator()->enrol_user($this->u6->id, $this->c1->id, 'student');
$this->getDataGenerator()->enrol_user($this->u7->id, $this->c1->id, 'student');
$this->getDataGenerator()->enrol_user($this->u8->id, $this->c1->id, 'student');
$this->getDataGenerator()->enrol_user($this->u1->id, $this->c2->id, 'student');
$this->getDataGenerator()->enrol_user($this->u2->id, $this->c2->id, 'student');
$this->getDataGenerator()->enrol_user($this->u3->id, $this->c2->id, 'student');
$this->getDataGenerator()->enrol_user($this->u4->id, $this->c2->id, 'student');
$this->getDataGenerator()->enrol_user($this->u5->id, $this->c2->id, 'student');
$this->getDataGenerator()->enrol_user($this->u6->id, $this->c2->id, 'student');
$this->getDataGenerator()->enrol_user($this->u7->id, $this->c2->id, 'student');
$this->getDataGenerator()->enrol_user($this->u8->id, $this->c2->id, 'student');
$this->setAdminUser();
$this->model1->enable();
$this->model1->train();
$this->model1->predict();
$this->model2->enable();
$this->model2->train();
$this->model2->predict();
list($total, $predictions) = $this->model2->get_predictions(\context_course::instance($this->c1->id));
$this->setUser($this->u3);
$prediction = reset($predictions);
$prediction->action_executed(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $this->model2->get_target());
$this->setAdminUser();
}
/**
* Test fetching users within a context.
*/
public function test_get_users_in_context(): void {
global $CFG;
$component = 'core_analytics';
$course1context = \context_course::instance($this->c1->id);
$course2context = \context_course::instance($this->c2->id);
$systemcontext = \context_system::instance();
$expected = [$this->u1->id, $this->u2->id, $this->u3->id, $this->u4->id, $this->u5->id, $this->u6->id,
$this->u7->id, $this->u8->id];
// Check users exist in the relevant contexts.
$userlist = new \core_privacy\local\request\userlist($course1context, $component);
provider::get_users_in_context($userlist);
$actual = $userlist->get_userids();
sort($actual);
$this->assertEquals($expected, $actual);
$userlist = new \core_privacy\local\request\userlist($course2context, $component);
provider::get_users_in_context($userlist);
$actual = $userlist->get_userids();
sort($actual);
$this->assertEquals($expected, $actual);
// System context will also find guest and admin user, add to expected before testing.
$expected = array_merge($expected, [$CFG->siteguest, get_admin()->id]);
sort($expected);
$userlist = new \core_privacy\local\request\userlist($systemcontext, $component);
provider::get_users_in_context($userlist);
$actual = $userlist->get_userids();
sort($actual);
$this->assertEquals($expected, $actual);
}
/**
* Test delete a context.
*
* @return null
*/
public function test_delete_context_data(): void {
global $DB;
// We have 4 predictions for model1 and 8 predictions for model2.
$this->assertEquals(12, $DB->count_records('analytics_predictions'));
$this->assertEquals(26, $DB->count_records('analytics_indicator_calc'));
// We have 1 prediction action.
$this->assertEquals(1, $DB->count_records('analytics_prediction_actions'));
$coursecontext = \context_course::instance($this->c1->id);
// Delete the course that was used for prediction.
provider::delete_data_for_all_users_in_context($coursecontext);
// The course1 predictions are deleted.
$this->assertEquals(8, $DB->count_records('analytics_predictions'));
// Calculations related to that context are deleted.
$this->assertEmpty($DB->count_records('analytics_indicator_calc', ['contextid' => $coursecontext->id]));
// The deleted context prediction actions are deleted as well.
$this->assertEquals(0, $DB->count_records('analytics_prediction_actions'));
}
/**
* Test delete a user.
*
* @return null
*/
public function test_delete_user_data(): void {
global $DB;
$usercontexts = provider::get_contexts_for_userid($this->u3->id);
$contextlist = new \core_privacy\local\request\approved_contextlist($this->u3, 'core_analytics',
$usercontexts->get_contextids());
provider::delete_data_for_user($contextlist);
// The site level prediction for u3 was deleted.
$this->assertEquals(9, $DB->count_records('analytics_predictions'));
$this->assertEquals(0, $DB->count_records('analytics_prediction_actions'));
$usercontexts = provider::get_contexts_for_userid($this->u1->id);
$contextlist = new \core_privacy\local\request\approved_contextlist($this->u1, 'core_analytics',
$usercontexts->get_contextids());
provider::delete_data_for_user($contextlist);
// We have nothing for u1.
$this->assertEquals(9, $DB->count_records('analytics_predictions'));
$usercontexts = provider::get_contexts_for_userid($this->u4->id);
$contextlist = new \core_privacy\local\request\approved_contextlist($this->u4, 'core_analytics',
$usercontexts->get_contextids());
provider::delete_data_for_user($contextlist);
$this->assertEquals(6, $DB->count_records('analytics_predictions'));
}
/**
* Test deleting multiple users in a context.
*/
public function test_delete_data_for_users(): void {
global $DB;
$component = 'core_analytics';
$course1context = \context_course::instance($this->c1->id);
$course2context = \context_course::instance($this->c2->id);
$systemcontext = \context_system::instance();
// Ensure all records exist in expected contexts.
$expectedcontexts = [$course1context->id, $course2context->id, $systemcontext->id];
sort($expectedcontexts);
$actualcontexts = [
$this->u1->id => provider::get_contexts_for_userid($this->u1->id)->get_contextids(),
$this->u2->id => provider::get_contexts_for_userid($this->u2->id)->get_contextids(),
$this->u3->id => provider::get_contexts_for_userid($this->u3->id)->get_contextids(),
$this->u4->id => provider::get_contexts_for_userid($this->u4->id)->get_contextids(),
$this->u5->id => provider::get_contexts_for_userid($this->u5->id)->get_contextids(),
$this->u6->id => provider::get_contexts_for_userid($this->u6->id)->get_contextids(),
$this->u7->id => provider::get_contexts_for_userid($this->u7->id)->get_contextids(),
$this->u8->id => provider::get_contexts_for_userid($this->u8->id)->get_contextids(),
];
foreach ($actualcontexts as $userid => $unused) {
sort($actualcontexts[$userid]);
$this->assertEquals($expectedcontexts, $actualcontexts[$userid]);
}
// Test initial record counts are as expected.
$this->assertEquals(12, $DB->count_records('analytics_predictions'));
$this->assertEquals(1, $DB->count_records('analytics_prediction_actions'));
$this->assertEquals(26, $DB->count_records('analytics_indicator_calc'));
// Delete u1 and u3 from system context.
$approveduserids = [$this->u1->id, $this->u3->id];
$approvedlist = new approved_userlist($systemcontext, $component, $approveduserids);
provider::delete_data_for_users($approvedlist);
// Ensure u1 and u3 system context data deleted only.
$expectedcontexts = [
$this->u1->id => [$course1context->id, $course2context->id],
$this->u2->id => [$systemcontext->id, $course1context->id, $course2context->id],
$this->u3->id => [$course1context->id, $course2context->id],
$this->u4->id => [$systemcontext->id, $course1context->id, $course2context->id],
$this->u5->id => [$systemcontext->id, $course1context->id, $course2context->id],
$this->u6->id => [$systemcontext->id, $course1context->id, $course2context->id],
$this->u7->id => [$systemcontext->id, $course1context->id, $course2context->id],
$this->u8->id => [$systemcontext->id, $course1context->id, $course2context->id],
];
$actualcontexts = [
$this->u1->id => provider::get_contexts_for_userid($this->u1->id)->get_contextids(),
$this->u2->id => provider::get_contexts_for_userid($this->u2->id)->get_contextids(),
$this->u3->id => provider::get_contexts_for_userid($this->u3->id)->get_contextids(),
$this->u4->id => provider::get_contexts_for_userid($this->u4->id)->get_contextids(),
$this->u5->id => provider::get_contexts_for_userid($this->u5->id)->get_contextids(),
$this->u6->id => provider::get_contexts_for_userid($this->u6->id)->get_contextids(),
$this->u7->id => provider::get_contexts_for_userid($this->u7->id)->get_contextids(),
$this->u8->id => provider::get_contexts_for_userid($this->u8->id)->get_contextids(),
];
foreach ($actualcontexts as $userid => $unused) {
sort($expectedcontexts[$userid]);
sort($actualcontexts[$userid]);
$this->assertEquals($expectedcontexts[$userid], $actualcontexts[$userid]);
}
// Test expected number of records have been deleted.
$this->assertEquals(11, $DB->count_records('analytics_predictions'));
$this->assertEquals(1, $DB->count_records('analytics_prediction_actions'));
$this->assertEquals(24, $DB->count_records('analytics_indicator_calc'));
// Delete for all 8 users in course 2 context.
$approveduserids = [$this->u1->id, $this->u2->id, $this->u3->id, $this->u4->id, $this->u5->id, $this->u6->id,
$this->u7->id, $this->u8->id];
$approvedlist = new approved_userlist($course2context, $component, $approveduserids);
provider::delete_data_for_users($approvedlist);
// Ensure all course 2 context data deleted for all 4 users.
$expectedcontexts = [
$this->u1->id => [$course1context->id],
$this->u2->id => [$systemcontext->id, $course1context->id],
$this->u3->id => [$course1context->id],
$this->u4->id => [$systemcontext->id, $course1context->id],
$this->u5->id => [$systemcontext->id, $course1context->id],
$this->u6->id => [$systemcontext->id, $course1context->id],
$this->u7->id => [$systemcontext->id, $course1context->id],
$this->u8->id => [$systemcontext->id, $course1context->id],
];
$actualcontexts = [
$this->u1->id => provider::get_contexts_for_userid($this->u1->id)->get_contextids(),
$this->u2->id => provider::get_contexts_for_userid($this->u2->id)->get_contextids(),
$this->u3->id => provider::get_contexts_for_userid($this->u3->id)->get_contextids(),
$this->u4->id => provider::get_contexts_for_userid($this->u4->id)->get_contextids(),
$this->u5->id => provider::get_contexts_for_userid($this->u5->id)->get_contextids(),
$this->u6->id => provider::get_contexts_for_userid($this->u6->id)->get_contextids(),
$this->u7->id => provider::get_contexts_for_userid($this->u7->id)->get_contextids(),
$this->u8->id => provider::get_contexts_for_userid($this->u8->id)->get_contextids(),
];
foreach ($actualcontexts as $userid => $unused) {
sort($actualcontexts[$userid]);
sort($expectedcontexts[$userid]);
$this->assertEquals($expectedcontexts[$userid], $actualcontexts[$userid]);
}
// Test expected number of records have been deleted.
$this->assertEquals(7, $DB->count_records('analytics_predictions'));
$this->assertEquals(1, $DB->count_records('analytics_prediction_actions'));
$this->assertEquals(16, $DB->count_records('analytics_indicator_calc'));
$approveduserids = [$this->u3->id];
$approvedlist = new approved_userlist($course1context, $component, $approveduserids);
provider::delete_data_for_users($approvedlist);
// Ensure all course 1 context data deleted for u3.
$expectedcontexts = [
$this->u1->id => [$course1context->id],
$this->u2->id => [$systemcontext->id, $course1context->id],
$this->u3->id => [],
$this->u4->id => [$systemcontext->id, $course1context->id],
$this->u5->id => [$systemcontext->id, $course1context->id],
$this->u6->id => [$systemcontext->id, $course1context->id],
$this->u7->id => [$systemcontext->id, $course1context->id],
$this->u8->id => [$systemcontext->id, $course1context->id],
];
$actualcontexts = [
$this->u1->id => provider::get_contexts_for_userid($this->u1->id)->get_contextids(),
$this->u2->id => provider::get_contexts_for_userid($this->u2->id)->get_contextids(),
$this->u3->id => provider::get_contexts_for_userid($this->u3->id)->get_contextids(),
$this->u4->id => provider::get_contexts_for_userid($this->u4->id)->get_contextids(),
$this->u5->id => provider::get_contexts_for_userid($this->u5->id)->get_contextids(),
$this->u6->id => provider::get_contexts_for_userid($this->u6->id)->get_contextids(),
$this->u7->id => provider::get_contexts_for_userid($this->u7->id)->get_contextids(),
$this->u8->id => provider::get_contexts_for_userid($this->u8->id)->get_contextids(),
];
foreach ($actualcontexts as $userid => $unused) {
sort($actualcontexts[$userid]);
sort($expectedcontexts[$userid]);
$this->assertEquals($expectedcontexts[$userid], $actualcontexts[$userid]);
}
// Test expected number of records have been deleted.
$this->assertEquals(6, $DB->count_records('analytics_predictions'));
$this->assertEquals(0, $DB->count_records('analytics_prediction_actions'));
$this->assertEquals(15, $DB->count_records('analytics_indicator_calc'));
}
/**
* Test export user data.
*
* @return null
*/
public function test_export_data(): void {
global $DB;
$system = \context_system::instance();
list($total, $predictions) = $this->model1->get_predictions($system);
foreach ($predictions as $key => $prediction) {
if ($prediction->get_prediction_data()->sampleid !== $this->u3->id) {
$otheruserprediction = $prediction;
break;
}
}
$this->setUser($this->u3);
$otheruserprediction->action_executed(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $this->model1->get_target());
$this->setAdminUser();
$this->export_context_data_for_user($this->u3->id, $system, 'core_analytics');
$writer = \core_privacy\local\request\writer::with_context($system);
$this->assertTrue($writer->has_any_data());
$u3prediction = $DB->get_record('analytics_predictions', ['contextid' => $system->id, 'sampleid' => $this->u3->id]);
$data = $writer->get_data([get_string('analytics', 'analytics'),
get_string('privacy:metadata:analytics:predictions', 'analytics'), $u3prediction->id]);
$this->assertEquals(get_string('adminhelplogs'), $data->target);
$this->assertEquals(get_string('coresystem'), $data->context);
$this->assertEquals('firstname first char is not A', $data->prediction);
$u3calculation = $DB->get_record('analytics_indicator_calc', ['contextid' => $system->id, 'sampleid' => $this->u3->id]);
$data = $writer->get_data([get_string('analytics', 'analytics'),
get_string('privacy:metadata:analytics:indicatorcalc', 'analytics'), $u3calculation->id]);
$this->assertEquals('Allow stealth activities', $data->indicator);
$this->assertEquals(get_string('coresystem'), $data->context);
$this->assertEquals(get_string('yes'), $data->calculation);
$sql = "SELECT apa.id FROM {analytics_prediction_actions} apa
JOIN {analytics_predictions} ap ON ap.id = apa.predictionid
WHERE ap.contextid = :contextid AND apa.userid = :userid AND ap.modelid = :modelid";
$params = ['contextid' => $system->id, 'userid' => $this->u3->id, 'modelid' => $this->model1->get_id()];
$u3action = $DB->get_record_sql($sql, $params);
$data = $writer->get_data([get_string('analytics', 'analytics'),
get_string('privacy:metadata:analytics:predictionactions', 'analytics'), $u3action->id]);
$this->assertEquals(get_string('adminhelplogs'), $data->target);
$this->assertEquals(get_string('coresystem'), $data->context);
$this->assertEquals(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $data->action);
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
// This file is part of Moodle - https://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/>.
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/fixtures/test_indicator_fullname.php');
require_once(__DIR__ . '/fixtures/test_target_shortname.php');
/**
* Unit tests for the analytics stats.
*
* @package core_analytics
* @category test
* @copyright 2019 David Mudrák <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class stats_test extends \advanced_testcase {
/**
* Set up the test environment.
*/
public function setUp(): void {
$this->setAdminUser();
}
/**
* Test the {@link \core_analytics\stats::enabled_models()} implementation.
*/
public function test_enabled_models(): void {
$this->resetAfterTest(true);
// By default, sites have {@link \core_course\analytics\target\no_teaching} and
// {@link \core_user\analytics\target\upcoming_activities_due} enabled.
$this->assertEquals(4, \core_analytics\stats::enabled_models());
$model = \core_analytics\model::create(
\core_analytics\manager::get_target('\core_course\analytics\target\course_dropout'),
[
\core_analytics\manager::get_indicator('\core\analytics\indicator\any_write_action'),
]
);
// Purely adding a new model does not make it included in the stats.
$this->assertEquals(4, \core_analytics\stats::enabled_models());
// New models must be enabled to have them counted.
$model->enable('\core\analytics\time_splitting\quarters');
$this->assertEquals(5, \core_analytics\stats::enabled_models());
}
/**
* Test the {@link \core_analytics\stats::predictions()} implementation.
*/
public function test_predictions(): void {
$this->resetAfterTest(true);
$model = \core_analytics\model::create(
\core_analytics\manager::get_target('test_target_shortname'),
[
\core_analytics\manager::get_indicator('test_indicator_fullname'),
]
);
$model->enable('\core\analytics\time_splitting\no_splitting');
// Train the model.
$this->getDataGenerator()->create_course(['shortname' => 'a', 'fullname' => 'a', 'visible' => 1]);
$this->getDataGenerator()->create_course(['shortname' => 'b', 'fullname' => 'b', 'visible' => 1]);
$model->train();
// No predictions yet.
$this->assertEquals(0, \core_analytics\stats::predictions());
// Get one new prediction.
$this->getDataGenerator()->create_course(['shortname' => 'aa', 'fullname' => 'aa', 'visible' => 0]);
$result = $model->predict();
$this->assertEquals(1, count($result->predictions));
$this->assertEquals(1, \core_analytics\stats::predictions());
// Nothing changes if there is no new prediction.
$result = $model->predict();
$this->assertFalse(isset($result->predictions));
$this->assertEquals(1, \core_analytics\stats::predictions());
// Get two more predictions, we have three in total now.
$this->getDataGenerator()->create_course(['shortname' => 'bb', 'fullname' => 'bb', 'visible' => 0]);
$this->getDataGenerator()->create_course(['shortname' => 'cc', 'fullname' => 'cc', 'visible' => 0]);
$result = $model->predict();
$this->assertEquals(2, count($result->predictions));
$this->assertEquals(3, \core_analytics\stats::predictions());
}
/**
* Test the {@link \core_analytics\stats::actions()} and {@link \core_analytics\stats::actions_not_useful()} implementation.
*/
public function test_actions(): void {
global $DB;
$this->resetAfterTest(true);
$model = \core_analytics\model::create(
\core_analytics\manager::get_target('test_target_shortname'),
[
\core_analytics\manager::get_indicator('test_indicator_fullname'),
]
);
$model->enable('\core\analytics\time_splitting\no_splitting');
// Train the model.
$this->getDataGenerator()->create_course(['shortname' => 'a', 'fullname' => 'a', 'visible' => 1]);
$this->getDataGenerator()->create_course(['shortname' => 'b', 'fullname' => 'b', 'visible' => 1]);
$model->train();
// Generate two predictions.
$this->getDataGenerator()->create_course(['shortname' => 'aa', 'fullname' => 'aa', 'visible' => 0]);
$this->getDataGenerator()->create_course(['shortname' => 'bb', 'fullname' => 'bb', 'visible' => 0]);
$model->predict();
list($p1, $p2) = array_values($DB->get_records('analytics_predictions'));
$p1 = new \core_analytics\prediction($p1, []);
$p2 = new \core_analytics\prediction($p2, []);
// No actions executed at the start.
$this->assertEquals(0, \core_analytics\stats::actions());
$this->assertEquals(0, \core_analytics\stats::actions_not_useful());
// The user has acknowledged the first prediction.
$p1->action_executed(\core_analytics\prediction::ACTION_FIXED, $model->get_target());
$this->assertEquals(1, \core_analytics\stats::actions());
$this->assertEquals(0, \core_analytics\stats::actions_not_useful());
// The user has marked the other prediction as not useful.
$p2->action_executed(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $model->get_target());
$this->assertEquals(2, \core_analytics\stats::actions());
$this->assertEquals(1, \core_analytics\stats::actions_not_useful());
}
}
+385
View File
@@ -0,0 +1,385 @@
<?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/>.
namespace core_analytics;
use test_timesplitting_seconds;
use test_timesplitting_upcoming_seconds;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/fixtures/test_timesplitting_seconds.php');
require_once(__DIR__ . '/fixtures/test_timesplitting_upcoming_seconds.php');
require_once(__DIR__ . '/../../lib/enrollib.php');
/**
* Unit tests for core time splitting methods.
*
* @package core
* @category test
* @copyright 2017 David Monllaó {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class time_splittings_test extends \advanced_testcase {
/** @var \stdClass course record. */
protected $course;
/** @var course Moodle course analysable. */
protected $analysable;
/**
* setUp
*
* @return void
*/
public function setUp(): void {
$this->resetAfterTest(true);
// Generate training data.
$params = array(
'startdate' => mktime(8, 15, 32, 10, 24, 2015),
'enddate' => mktime(12, 12, 31, 10, 24, 2016),
);
$this->course = $this->getDataGenerator()->create_course($params);
$this->analysable = new \core_analytics\course($this->course);
}
/**
* test_ranges
*
* @return void
*/
public function test_valid_ranges(): void {
// All core time splitting methods.
$timesplittings = array(
'\core\analytics\time_splitting\deciles',
'\core\analytics\time_splitting\deciles_accum',
'\core\analytics\time_splitting\no_splitting',
'\core\analytics\time_splitting\quarters',
'\core\analytics\time_splitting\quarters_accum',
'\core\analytics\time_splitting\single_range',
'\core\analytics\time_splitting\upcoming_week',
);
// Check that defined ranges are valid (tested through validate_ranges).
foreach ($timesplittings as $timesplitting) {
$instance = new $timesplitting();
$instance->set_analysable($this->analysable);
}
}
/**
* test_range_dates
*
* @return void
*/
public function test_range_dates(): void {
$nov2015 = mktime(0, 0, 0, 11, 24, 2015);
$aug2016 = mktime(0, 0, 0, 8, 29, 2016);
// Equal parts.
$quarters = new \core\analytics\time_splitting\quarters();
$quarters->set_analysable($this->analysable);
$ranges = $quarters->get_all_ranges();
$this->assertCount(4, $ranges);
$this->assertCount(4, $quarters->get_training_ranges());
$this->assertCount(4, $quarters->get_distinct_ranges());
$this->assertGreaterThan($ranges[0]['start'], $ranges[1]['start']);
$this->assertGreaterThan($ranges[0]['end'], $ranges[1]['start']);
$this->assertGreaterThan($ranges[0]['end'], $ranges[1]['end']);
$this->assertGreaterThan($ranges[1]['start'], $ranges[2]['start']);
$this->assertGreaterThan($ranges[1]['end'], $ranges[2]['start']);
$this->assertGreaterThan($ranges[1]['end'], $ranges[2]['end']);
$this->assertGreaterThan($ranges[2]['start'], $ranges[3]['start']);
$this->assertGreaterThan($ranges[2]['end'], $ranges[3]['end']);
$this->assertGreaterThan($ranges[2]['end'], $ranges[3]['start']);
// First range.
$this->assertLessThan($nov2015, $ranges[0]['start']);
$this->assertGreaterThan($nov2015, $ranges[0]['end']);
// Last range.
$this->assertLessThan($aug2016, $ranges[3]['start']);
$this->assertGreaterThan($aug2016, $ranges[3]['end']);
// Accumulative.
$accum = new \core\analytics\time_splitting\quarters_accum();
$accum->set_analysable($this->analysable);
$ranges = $accum->get_all_ranges();
$this->assertCount(4, $ranges);
$this->assertCount(4, $accum->get_training_ranges());
$this->assertCount(4, $accum->get_distinct_ranges());
$this->assertEquals($ranges[0]['start'], $ranges[1]['start']);
$this->assertEquals($ranges[1]['start'], $ranges[2]['start']);
$this->assertEquals($ranges[2]['start'], $ranges[3]['start']);
$this->assertGreaterThan($ranges[0]['end'], $ranges[1]['end']);
$this->assertGreaterThan($ranges[1]['end'], $ranges[2]['end']);
$this->assertGreaterThan($ranges[2]['end'], $ranges[3]['end']);
// Present in all ranges.
$this->assertLessThan($nov2015, $ranges[0]['start']);
$this->assertGreaterThan($nov2015, $ranges[0]['end']);
$this->assertGreaterThan($nov2015, $ranges[1]['end']);
$this->assertGreaterThan($nov2015, $ranges[2]['end']);
$this->assertGreaterThan($nov2015, $ranges[3]['end']);
// Only in the last range.
$this->assertLessThan($aug2016, $ranges[0]['end']);
$this->assertLessThan($aug2016, $ranges[1]['end']);
$this->assertLessThan($aug2016, $ranges[2]['end']);
$this->assertLessThan($aug2016, $ranges[3]['start']);
$this->assertGreaterThan($aug2016, $ranges[3]['end']);
}
/**
* test_ready_predict
*
* @return void
*/
public function test_ready_predict(): void {
$quarters = new \core\analytics\time_splitting\quarters();
$nosplitting = new \core\analytics\time_splitting\no_splitting();
$singlerange = new \core\analytics\time_splitting\single_range();
$range = array(
'start' => time() - 100,
'end' => time() - 20,
);
$range['time'] = $range['end'];
$this->assertTrue($quarters->ready_to_predict($range));
$this->assertTrue($nosplitting->ready_to_predict($range));
// Single range time is 0.
$range['time'] = 0;
$this->assertTrue($singlerange->ready_to_predict($range));
$range = array(
'start' => time() + 20,
'end' => time() + 100,
);
$range['time'] = $range['end'];
$this->assertFalse($quarters->ready_to_predict($range));
$this->assertTrue($nosplitting->ready_to_predict($range));
// Single range time is 0.
$range['time'] = 0;
$this->assertTrue($singlerange->ready_to_predict($range));
}
/**
* test_periodic
*
* @return void
*/
public function test_periodic(): void {
// Using a finished course.
$pastweek = new \core\analytics\time_splitting\past_week();
$pastweek->set_analysable($this->analysable);
$this->assertCount(1, $pastweek->get_distinct_ranges());
$ranges = $pastweek->get_all_ranges();
$this->assertEquals(52, count($ranges));
$this->assertEquals($this->course->startdate, $ranges[0]['start']);
$this->assertNotEquals($this->course->startdate, $ranges[0]['time']);
// The analysable is finished so all ranges are available for training.
$this->assertCount(count($ranges), $pastweek->get_training_ranges());
$ranges = $pastweek->get_most_recent_prediction_range();
$range = reset($ranges);
$this->assertEquals(51, key($ranges));
// We now use an ongoing course not yet ready to generate predictions.
$threedaysago = new \DateTime('-3 days');
$params = array(
'startdate' => $threedaysago->getTimestamp(),
);
$ongoingcourse = $this->getDataGenerator()->create_course($params);
$ongoinganalysable = new \core_analytics\course($ongoingcourse);
$pastweek = new \core\analytics\time_splitting\past_week();
$pastweek->set_analysable($ongoinganalysable);
$ranges = $pastweek->get_all_ranges();
$this->assertEquals(0, count($ranges));
$this->assertCount(0, $pastweek->get_training_ranges());
// We now use a ready-to-predict ongoing course.
$onemonthago = new \DateTime('-30 days');
$params = array(
'startdate' => $onemonthago->getTimestamp(),
);
$ongoingcourse = $this->getDataGenerator()->create_course($params);
$ongoinganalysable = new \core_analytics\course($ongoingcourse);
$pastweek = new \core\analytics\time_splitting\past_week();
$pastweek->set_analysable($ongoinganalysable);
$this->assertCount(1, $pastweek->get_distinct_ranges());
$ranges = $pastweek->get_all_ranges();
$this->assertEquals(4, count($ranges));
$this->assertCount(4, $pastweek->get_training_ranges());
$ranges = $pastweek->get_most_recent_prediction_range();
$range = reset($ranges);
$this->assertEquals(3, key($ranges));
$this->assertEqualsWithDelta(time(), $range['time'], 1);
// 1 second delta for the start just in case a second passes between the set_analysable call
// and this checking below.
$time = new \DateTime();
$time->sub($pastweek->periodicity());
$this->assertEqualsWithDelta($time->getTimestamp(), $range['start'], 1.0);
$this->assertEqualsWithDelta(time(), $range['end'], 1);
$starttime = time();
$upcomingweek = new \core\analytics\time_splitting\upcoming_week();
$upcomingweek->set_analysable($ongoinganalysable);
$this->assertCount(1, $upcomingweek->get_distinct_ranges());
$ranges = $upcomingweek->get_all_ranges();
$this->assertEquals(1, count($ranges));
$range = reset($ranges);
$this->assertEqualsWithDelta(time(), $range['time'], 1);
$this->assertEqualsWithDelta(time(), $range['start'], 1);
$this->assertGreaterThan(time(), $range['end']);
$this->assertCount(0, $upcomingweek->get_training_ranges());
$ranges = $upcomingweek->get_most_recent_prediction_range();
$range = reset($ranges);
$this->assertEquals(0, key($ranges));
$this->assertEqualsWithDelta(time(), $range['time'], 1);
$this->assertEqualsWithDelta(time(), $range['start'], 1);
$this->assertGreaterThanOrEqual($starttime, $range['time']);
$this->assertGreaterThanOrEqual($starttime, $range['start']);
$this->assertGreaterThan(time(), $range['end']);
$this->assertNotEmpty($upcomingweek->get_range_by_index(0));
$this->assertFalse($upcomingweek->get_range_by_index(1));
// We now check how new ranges get added as time passes.
$fewsecsago = new \DateTime('-5 seconds');
$params = array(
'startdate' => $fewsecsago->getTimestamp(),
'enddate' => (new \DateTimeImmutable('+1 year'))->getTimestamp(),
);
$course = $this->getDataGenerator()->create_course($params);
$analysable = new \core_analytics\course($course);
$seconds = new test_timesplitting_seconds();
$seconds->set_analysable($analysable);
// Store the ranges we just obtained.
$ranges = $seconds->get_all_ranges();
$nranges = count($ranges);
$ntrainingranges = count($seconds->get_training_ranges());
$mostrecentrange = $seconds->get_most_recent_prediction_range();
$mostrecentrange = reset($mostrecentrange);
// We wait for the next range to be added.
sleep(1);
// We set the analysable again so the time ranges are recalculated.
$seconds->set_analysable($analysable);
$newranges = $seconds->get_all_ranges();
$nnewranges = count($newranges);
$nnewtrainingranges = $seconds->get_training_ranges();
$newmostrecentrange = $seconds->get_most_recent_prediction_range();
$newmostrecentrange = reset($newmostrecentrange);
$this->assertGreaterThan($nranges, $nnewranges);
$this->assertGreaterThan($ntrainingranges, $nnewtrainingranges);
$this->assertGreaterThan($mostrecentrange['time'], $newmostrecentrange['time']);
// All the ranges but the last one should return the same values.
array_pop($ranges);
array_pop($newranges);
foreach ($ranges as $key => $range) {
$this->assertEquals($newranges[$key]['start'], $range['start']);
$this->assertEquals($newranges[$key]['end'], $range['end']);
$this->assertEquals($newranges[$key]['time'], $range['time']);
}
// Fake model id, we can use any int, we will need to reference it later.
$modelid = 1505347200;
$upcomingseconds = new test_timesplitting_upcoming_seconds();
$upcomingseconds->set_modelid($modelid);
$upcomingseconds->set_analysable($analysable);
// Store the ranges we just obtained.
$ranges = $upcomingseconds->get_all_ranges();
$nranges = count($ranges);
$ntrainingranges = count($upcomingseconds->get_training_ranges());
$mostrecentrange = $upcomingseconds->get_most_recent_prediction_range();
$mostrecentrange = reset($mostrecentrange);
// Mimic the modelfirstanalyses caching in \core_analytics\analysis.
$this->mock_cache_first_analysis_caching($modelid, $analysable->get_id(), end($ranges));
// We wait for the next range to be added.
sleep(1);
// We set the analysable again so the time ranges are recalculated.
$upcomingseconds->set_analysable($analysable);
$newranges = $upcomingseconds->get_all_ranges();
$nnewranges = count($newranges);
$nnewtrainingranges = $upcomingseconds->get_training_ranges();
$newmostrecentrange = $upcomingseconds->get_most_recent_prediction_range();
$newmostrecentrange = reset($newmostrecentrange);
$this->assertGreaterThan($nranges, $nnewranges);
$this->assertGreaterThan($ntrainingranges, $nnewtrainingranges);
$this->assertGreaterThan($mostrecentrange['time'], $newmostrecentrange['time']);
// All the ranges but the last one should return the same values.
array_pop($ranges);
array_pop($newranges);
foreach ($ranges as $key => $range) {
$this->assertEquals($newranges[$key]['start'], $range['start']);
$this->assertEquals($newranges[$key]['end'], $range['end']);
$this->assertEquals($newranges[$key]['time'], $range['time']);
}
}
/**
* Mocks core_analytics\analysis caching of the first time analysables were analysed.
*
* @param int $modelid
* @param int $analysableid
* @param array $range
* @return null
*/
private function mock_cache_first_analysis_caching($modelid, $analysableid, $range) {
$cache = \cache::make('core', 'modelfirstanalyses');
$cache->set($modelid . '_' . $analysableid, $range['time']);
}
}
+112
View File
@@ -0,0 +1,112 @@
This files describes API changes in analytics sub system,
information provided here is intended especially for developers.
=== 4.2 ===
* The following analytics target classes, renamed since 3.7, have now been removed completed:
- `\core\analytics\target\course_dropout`
- `\core\analytics\target\course_competencies`
- `\core\analytics\target\course_completion`
- `\core\analytics\target\course_gradetopass`
- `\core\analytics\target\no_teaching`
=== 3.11 ===
* Final deprecation get_enabled_time_splitting_methods. Method has been removed. Use
get_time_splitting_methods_for_evaluation instead.
* Final deprecation add_builtin_models. Method has been removed. The functionality
has been replaced with automatic update of models provided by the core moodle component.
There is no need to call this method explicitly any more. Instead, adding new models can be achieved
by updating the lib/db/analytics.php file and bumping the core version.
* Final deprecation - get_analysables(). Please see get_analysables_interator() instead.
get_analysables_iterator() needs to be overridden by the child class.
* A new function get_executed_actions() has been added to \core_analytics\prediction class
to get all (or filtered by action name) executed actions of a prediction
=== 3.8 ===
* "Time-splitting method" have been replaced by "Analysis interval" for the language strings that are
displayed in the Moodle UI. The name of several time-splitting methods have been updated according
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')
* A new \core_analytics\local\time_splitting\past_periodic abstract class has been added. Time-splitting
methods extending \core_analytics\local\time_splitting\periodic directly should be extending past_periodic
now. 'periodic' can still be directly extended by implementing get_next_range and get_first_start methods.
* Targets can now specify a list of bulk actions in bulk_actions(). core_analytics\prediction_action is now
extending core_analytics\action and a new core_analytics\bulk_action class has been added. Actions can now
specify a type in its constructor: core_analytics\action::TYPE_POSITIVE, TYPE_NEUTRAL or TYPE_NEGATIVE. A list
of default bulk actions is available in \core_analytics\default_bulk_actions.
* The default suggested actions provided to users changed:
* For targets with one single sample per analysable (e.g. upcoming activities due) the default actions are
Useful and Not useful.
* For targets with multiple samples per analysable (e.g. students at risk) the default actions are
Accept, Not applicable and Incorrectly flagged.
* The suggested actions for the existing models have been reworded:
* Predictions flagged as "Acknowledged" in models whose targets use analysers that provide one sample per
analysable (e.g. upcoming activities due) have been updated to "Useful" flag.
* Predictions flagged as "Not useful" in models whose targets use analysers that provide multiple samples
per analysable (e.g. students at risk or no teaching) have been updated to "Incorrectly flagged".
* \core_analytics\predictor::delete_output_dir has a new 2nd parameter, $uniquemodelid.
* Analyser's get_analysables_iterator and get_iterator_sql have a new $contexts parameter to limit the returned analysables to
the ones that depend on the provided contexts.
* Analysers can implement a context_restriction_support() method to restrict models to a subset of the
contents in the site. Only CONTEXT_COURSE and CONTEXT_COURSECAT are supported.
=== 3.7 ===
* \core_analytics\regressor::evaluate_regression and \core_analytics\classifier::evaluate_classification
have been updated to include a new $trainedmodeldir param. This new param will be used to evaluate the
existing trained model.
* Plugins and core subsystems can now declare default prediction models by describing them in
their db/analytics.php file. Models should not be created manually via the db/install.php
file any more.
* The method \core_analytics\manager::add_builtin_models() has been deprecated. The functionality
has been replaced with automatic update of models provided by the core moodle component. There
is no need to call this method explicitly any more. Instead, adding new models can be achieved
by updating the lib/db/analytics.php file and bumping the core version.
* \core_analytics\model::execute_prediction_callbacks now returns an array with both sample's contexts
and the prediction records.
* \core_analytics\model::export() now expects the renderer instance as an argument.
* Time splitting methods:
* \core_analytics\local\time_splitting\base::append_rangeindex and
\core_analytics\local\time_splitting\base::infer_sample_info are now marked as final and can not
be overwritten.
* Can now overwrite include_range_info_in_training_data() and
get_training_ranges() methods. They can be used to create time splitting methods with a pre-defined
number of ranges.
* Can now overwrite cache_indicator_calculations(). You should return false if the time frames generated
by your time-splitting method are unique and / or can hardly be reused by further models.
* Can now overwrite valid_for_evaluation(). You can return false if the time-splitting method can not be
used to evaluate prediction models or if it does not make sense to evaluate prediction models with it,
as for example upcoming_periodic children classes.
* \core_analytics\local\analyser\base::get_most_recent_prediction_range has been moved to
\core_analytics\local\time_splitting\base::get_most_recent_prediction_range and it is not overwritable
by time splitting methods.
* Targets:
* The visibility of the following methods must now be public: ignored_predicted_classes()
and get_insights_users()
* Prediction_actions() has now a 3rd parameter $isinsightuser. This parameter is true
when we are listing actions for the user that will receives the insight.
* Can now implement a always_update_analysis_time() method so analysable elements' timeanalysed is
only updated when analysable elements have been successfully evaluated. It is useful for lightweight targets.
* Can not implement two new methods to tune the insights generated by the model: get_insight_subject()
and get_insight_context_url().
* Analysers:
* The visibility of get_all_samples() method must now be public.
* get_analysables() method has been deprecated in favour of a new get_analysables_interator()
for performance reasons.
* Can overwrite a new one_sample_per_analysable() method if the analysables they use only have
one sample. The insights generated by models will then include the suggested actions in
the notification.
=== 3.5 ===
* There are two new methods for analysers, processes_user_data() and join_sample_user(). You
need to overwrite them if your analyser uses user data. As a general statement, you should
overwrite these new methods if your samples return 'user' data. These new methods are used
for analytics' privacy API implementation.