File manager - Edit - /home/u466501803/domains/qurdis.my.id/public_html/analytics.tar
Back
lib.php 0000644 00000003172 15215712354 0006033 0 ustar 00 <?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(); } } classes/stats.php 0000644 00000004604 15215712354 0010061 0 ustar 00 <?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]); } } classes/classifier.php 0000644 00000004415 15215712354 0011047 0 ustar 00 <?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); } classes/packable.php 0000644 00000004000 15215712354 0010453 0 ustar 00 <?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; } classes/analysable.php 0000644 00000003172 15215712354 0011035 0 ustar 00 <?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 = SQL_INT_MAX; /** * 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(); } classes/analysis.php 0000644 00000112116 15215712354 0010544 0 ustar 00 <?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; } } classes/requirements_exception.php 0000644 00000002344 15215712354 0013523 0 ustar 00 <?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 { } classes/admin_setting_predictor.php 0000644 00000005062 15215712354 0013622 0 ustar 00 <?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')); } } classes/prediction_action.php 0000644 00000006666 15215712354 0012432 0 ustar 00 <?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); } } classes/action.php 0000644 00000006733 15215712354 0010205 0 ustar 00 <?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; } } classes/privacy/provider.php 0000644 00000056543 15215712354 0012243 0 ustar 00 <?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]; } } classes/calculable.php 0000644 00000024511 15215712354 0011011 0 ustar 00 <?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; } } classes/predictor.php 0000644 00000005170 15215712354 0010715 0 ustar 00 <?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); } classes/model_config.php 0000644 00000026004 15215712354 0011346 0 ustar 00 <?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; } } classes/site.php 0000644 00000004742 15215712354 0007672 0 ustar 00 <?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 = self::MAX_TIME; 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; } } classes/calculation_info.php 0000644 00000014701 15215712354 0012233 0 ustar 00 <?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; } } classes/manager.php 0000644 00000112733 15215712354 0010340 0 ustar 00 <?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/>. /** * Analytics basic actions manager. * * @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(); /** * Analytics basic actions manager. * * @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 manager { /** * Default mlbackend */ const DEFAULT_MLBACKEND = '\mlbackend_php\processor'; /** * Name of the file where components declare their models. */ const ANALYTICS_FILENAME = 'db/analytics.php'; /** * @var \core_analytics\predictor[] */ protected static $predictionprocessors = []; /** * @var \core_analytics\local\target\base[] */ protected static $alltargets = null; /** * @var \core_analytics\local\indicator\base[] */ protected static $allindicators = null; /** * @var \core_analytics\local\time_splitting\base[] */ protected static $alltimesplittings = null; /** * Checks that the user can manage models * * @throws \required_capability_exception * @return void */ public static function check_can_manage_models() { require_capability('moodle/analytics:managemodels', \context_system::instance()); } /** * Checks that the user can list that context insights * * @throws \required_capability_exception * @param \context $context * @param bool $return The method returns a bool if true. * @return void */ public static function check_can_list_insights(\context $context, bool $return = false) { global $USER; if ($context->contextlevel === CONTEXT_USER && $context->instanceid == $USER->id) { $capability = 'moodle/analytics:listowninsights'; } else { $capability = 'moodle/analytics:listinsights'; } if ($return) { return has_capability($capability, $context); } else { require_capability($capability, $context); } } /** * Is analytics enabled globally? * * return bool */ public static function is_analytics_enabled(): bool { global $CFG; if (isset($CFG->enableanalytics)) { return $CFG->enableanalytics; } // Enabled by default. return true; } /** * Returns all system models that match the provided filters. * * @param bool $enabled * @param bool $trained * @param \context|false $predictioncontext * @return \core_analytics\model[] */ public static function get_all_models($enabled = false, $trained = false, $predictioncontext = false) { global $DB; $params = array(); $sql = "SELECT am.* FROM {analytics_models} am"; if ($enabled || $trained || $predictioncontext) { $conditions = []; if ($enabled) { $conditions[] = 'am.enabled = :enabled'; $params['enabled'] = 1; } if ($trained) { $conditions[] = 'am.trained = :trained'; $params['trained'] = 1; } if ($predictioncontext) { $conditions[] = "EXISTS (SELECT 'x' FROM {analytics_predictions} ap WHERE ap.modelid = am.id AND ap.contextid = :contextid)"; $params['contextid'] = $predictioncontext->id; } $sql .= ' WHERE ' . implode(' AND ', $conditions); } $sql .= ' ORDER BY am.enabled DESC, am.timemodified DESC'; $modelobjs = $DB->get_records_sql($sql, $params); $models = array(); foreach ($modelobjs as $modelobj) { $model = new \core_analytics\model($modelobj); if ($model->is_available()) { $models[$modelobj->id] = $model; } } // Sort the models by the model name using the current session language. \core_collator::asort_objects_by_method($models, 'get_name'); return $models; } /** * Returns the provided predictions processor class. * * @param false|string $predictionclass Returns the system default processor if false * @param bool $checkisready * @return \core_analytics\predictor */ public static function get_predictions_processor($predictionclass = false, $checkisready = true) { // We want 0 or 1 so we can use it as an array key for caching. $checkisready = intval($checkisready); if (!$predictionclass) { $predictionclass = get_config('analytics', 'predictionsprocessor'); } if (empty($predictionclass)) { // Use the default one if nothing set. $predictionclass = self::default_mlbackend(); } if (!class_exists($predictionclass)) { throw new \coding_exception('Invalid predictions processor ' . $predictionclass . '.'); } $interfaces = class_implements($predictionclass); if (empty($interfaces['core_analytics\predictor'])) { throw new \coding_exception($predictionclass . ' should implement \core_analytics\predictor.'); } // Return it from the cached list. if (!isset(self::$predictionprocessors[$checkisready][$predictionclass])) { $instance = new $predictionclass(); if ($checkisready) { $isready = $instance->is_ready(); if ($isready !== true) { throw new \moodle_exception('errorprocessornotready', 'analytics', '', $isready); } } self::$predictionprocessors[$checkisready][$predictionclass] = $instance; } return self::$predictionprocessors[$checkisready][$predictionclass]; } /** * Return all system predictions processors. * * @return \core_analytics\predictor[] */ public static function get_all_prediction_processors() { $mlbackends = \core_component::get_plugin_list('mlbackend'); $predictionprocessors = array(); foreach ($mlbackends as $mlbackend => $unused) { $classfullpath = '\mlbackend_' . $mlbackend . '\processor'; $predictionprocessors[$classfullpath] = self::get_predictions_processor($classfullpath, false); } return $predictionprocessors; } /** * Resets the cached prediction processors. * @return null */ public static function reset_prediction_processors() { self::$predictionprocessors = []; } /** * Returns the name of the provided predictions processor. * * @param \core_analytics\predictor $predictionsprocessor * @return string */ public static function get_predictions_processor_name(\core_analytics\predictor $predictionsprocessor) { $component = substr(get_class($predictionsprocessor), 0, strpos(get_class($predictionsprocessor), '\\', 1)); return get_string('pluginname', $component); } /** * Whether the provided plugin is used by any model. * * @param string $plugin * @return bool */ public static function is_mlbackend_used($plugin) { $models = self::get_all_models(); foreach ($models as $model) { $processor = $model->get_predictions_processor(); $noprefixnamespace = ltrim(get_class($processor), '\\'); $processorplugin = substr($noprefixnamespace, 0, strpos($noprefixnamespace, '\\')); if ($processorplugin == $plugin) { return true; } } // Default predictions processor. $defaultprocessorclass = get_config('analytics', 'predictionsprocessor'); $pluginclass = '\\' . $plugin . '\\processor'; if ($pluginclass === $defaultprocessorclass) { return true; } return false; } /** * Get all available time splitting methods. * * @return \core_analytics\local\time_splitting\base[] */ public static function get_all_time_splittings() { if (self::$alltimesplittings !== null) { return self::$alltimesplittings; } $classes = self::get_analytics_classes('time_splitting'); self::$alltimesplittings = []; foreach ($classes as $fullclassname => $classpath) { $instance = self::get_time_splitting($fullclassname); // We need to check that it is a valid time splitting method, it may be an abstract class. if ($instance) { self::$alltimesplittings[$instance->get_id()] = $instance; } } return self::$alltimesplittings; } /** * @deprecated since Moodle 3.7 use get_time_splitting_methods_for_evaluation instead */ public static function get_enabled_time_splitting_methods() { throw new coding_exception(__FUNCTION__ . '() has been removed. You can use self::get_time_splitting_methods_for_evaluation if ' . 'you want to get the default time splitting methods for evaluation, or you can use self::get_all_time_splittings if ' . 'you want to get all the time splitting methods available on this site.'); } /** * Returns the time-splitting methods for model evaluation. * * @param bool $all Return all the time-splitting methods that can potentially be used for evaluation or the default ones. * @return \core_analytics\local\time_splitting\base[] */ public static function get_time_splitting_methods_for_evaluation(bool $all = false) { if ($all === false) { if ($enabledtimesplittings = get_config('analytics', 'defaulttimesplittingsevaluation')) { $enabledtimesplittings = array_flip(explode(',', $enabledtimesplittings)); } } $timesplittings = self::get_all_time_splittings(); foreach ($timesplittings as $key => $timesplitting) { if (!$timesplitting->valid_for_evaluation()) { unset($timesplittings[$key]); } if ($all === false) { // We remove the ones that are not enabled. This also respects the default value (all methods enabled). if (!empty($enabledtimesplittings) && !isset($enabledtimesplittings[$key])) { unset($timesplittings[$key]); } } } return $timesplittings; } /** * Returns a time splitting method by its classname. * * @param string $fullclassname * @return \core_analytics\local\time_splitting\base|false False if it is not valid. */ public static function get_time_splitting($fullclassname) { if (!self::is_valid($fullclassname, '\core_analytics\local\time_splitting\base')) { return false; } return new $fullclassname(); } /** * Return all targets in the system. * * @return \core_analytics\local\target\base[] */ public static function get_all_targets(): array { if (self::$alltargets !== null) { return self::$alltargets; } $classes = self::get_analytics_classes('target'); self::$alltargets = []; foreach ($classes as $fullclassname => $classpath) { $instance = self::get_target($fullclassname); if ($instance) { self::$alltargets[$instance->get_id()] = $instance; } } return self::$alltargets; } /** * Return all system indicators. * * @return \core_analytics\local\indicator\base[] */ public static function get_all_indicators() { if (self::$allindicators !== null) { return self::$allindicators; } $classes = self::get_analytics_classes('indicator'); self::$allindicators = []; foreach ($classes as $fullclassname => $classpath) { $instance = self::get_indicator($fullclassname); if ($instance) { self::$allindicators[$instance->get_id()] = $instance; } } return self::$allindicators; } /** * Returns the specified target * * @param mixed $fullclassname * @return \core_analytics\local\target\base|false False if it is not valid */ public static function get_target($fullclassname) { if (!self::is_valid($fullclassname, 'core_analytics\local\target\base')) { return false; } return new $fullclassname(); } /** * Returns an instance of the provided indicator. * * @param string $fullclassname * @return \core_analytics\local\indicator\base|false False if it is not valid. */ public static function get_indicator($fullclassname) { if (!self::is_valid($fullclassname, 'core_analytics\local\indicator\base')) { return false; } return new $fullclassname(); } /** * Returns whether a time splitting method is valid or not. * * @param string $fullclassname * @param string $baseclass * @return bool */ public static function is_valid($fullclassname, $baseclass) { if (is_subclass_of($fullclassname, $baseclass)) { if ((new \ReflectionClass($fullclassname))->isInstantiable()) { return true; } } return false; } /** * Returns the logstore used for analytics. * * @return \core\log\sql_reader|false False if no log stores are enabled. */ public static function get_analytics_logstore() { $readers = get_log_manager()->get_readers('core\log\sql_reader'); $analyticsstore = get_config('analytics', 'logstore'); if (!empty($analyticsstore) && !empty($readers[$analyticsstore])) { $logstore = $readers[$analyticsstore]; } else if (empty($analyticsstore) && !empty($readers)) { // The first one, it is the same default than in settings. $logstore = reset($readers); } else if (!empty($readers)) { $logstore = reset($readers); debugging('The selected log store for analytics is not available anymore. Using "' . $logstore->get_name() . '"', DEBUG_DEVELOPER); } if (empty($logstore)) { debugging('No system log stores available to use for analytics', DEBUG_DEVELOPER); return false; } if (!$logstore->is_logging()) { debugging('The selected log store for analytics "' . $logstore->get_name() . '" is not logging activity logs', DEBUG_DEVELOPER); } return $logstore; } /** * Returns this analysable calculations during the provided period. * * @param \core_analytics\analysable $analysable * @param int $starttime * @param int $endtime * @param string $samplesorigin The samples origin as sampleid is not unique across models. * @return array */ public static function get_indicator_calculations($analysable, $starttime, $endtime, $samplesorigin) { global $DB; $params = array('starttime' => $starttime, 'endtime' => $endtime, 'contextid' => $analysable->get_context()->id, 'sampleorigin' => $samplesorigin); $calculations = $DB->get_recordset('analytics_indicator_calc', $params, '', 'indicator, sampleid, value'); $existingcalculations = array(); foreach ($calculations as $calculation) { if (empty($existingcalculations[$calculation->indicator])) { $existingcalculations[$calculation->indicator] = array(); } $existingcalculations[$calculation->indicator][$calculation->sampleid] = $calculation->value; } $calculations->close(); return $existingcalculations; } /** * Returns the models with insights at the provided context. * * Note that this method is used for display purposes. It filters out models whose insights * are not linked from the reports page. * * @param \context $context * @return \core_analytics\model[] */ public static function get_models_with_insights(\context $context) { self::check_can_list_insights($context); $models = self::get_all_models(true, true, $context); foreach ($models as $key => $model) { // Check that it not only have predictions but also generates insights from them. if (!$model->uses_insights() || !$model->get_target()->link_insights_report()) { unset($models[$key]); } } return $models; } /** * Returns the models that generated insights in the provided context. It can also be used to add new models to the context. * * Note that if you use this function with $newmodelid is the caller responsibility to ensure that the * provided model id generated insights for the provided context. * * @throws \coding_exception * @param \context $context * @param int|null $newmodelid A new model to add to the list of models with insights in the provided context. * @return int[] */ public static function cached_models_with_insights(\context $context, ?int $newmodelid = null) { $cache = \cache::make('core', 'contextwithinsights'); $modelids = $cache->get($context->id); if ($modelids === false) { // The cache is empty, but we don't know if it is empty because there are no insights // in this context or because cache/s have been purged, we need to be conservative and // "pay" 1 db read to fill up the cache. $models = \core_analytics\manager::get_models_with_insights($context); if ($newmodelid && empty($models[$newmodelid])) { throw new \coding_exception('The provided modelid ' . $newmodelid . ' did not generate any insights'); } $modelids = array_keys($models); $cache->set($context->id, $modelids); } else if ($newmodelid && !in_array($newmodelid, $modelids)) { // We add the context we got as an argument to the cache. array_push($modelids, $newmodelid); $cache->set($context->id, $modelids); } return $modelids; } /** * Returns a prediction * * @param int $predictionid * @param bool $requirelogin * @return array array($model, $prediction, $context) */ public static function get_prediction($predictionid, $requirelogin = false) { global $DB; if (!$predictionobj = $DB->get_record('analytics_predictions', array('id' => $predictionid))) { throw new \moodle_exception('errorpredictionnotfound', 'analytics'); } $context = \context::instance_by_id($predictionobj->contextid, IGNORE_MISSING); if (!$context) { throw new \moodle_exception('errorpredictioncontextnotavailable', 'analytics'); } if ($requirelogin) { list($context, $course, $cm) = get_context_info_array($predictionobj->contextid); require_login($course, false, $cm); } self::check_can_list_insights($context); $model = new \core_analytics\model($predictionobj->modelid); $sampledata = $model->prediction_sample_data($predictionobj); $prediction = new \core_analytics\prediction($predictionobj, $sampledata); return array($model, $prediction, $context); } /** * Used to be used to add models included with the Moodle core. * * @deprecated Deprecated since Moodle 3.7 (MDL-61667) - Use lib/db/analytics.php instead. * @todo Remove this method in Moodle 3.11 (MDL-65186). * @return void */ public static function add_builtin_models() { throw new \coding_exception('core_analytics\manager::add_builtin_models() has been removed. Core models ' . 'are now automatically updated according to their declaration in the lib/db/analytics.php file.'); } /** * Cleans up analytics db tables that do not directly depend on analysables that may have been deleted. */ public static function cleanup() { global $DB; $DB->execute("DELETE FROM {analytics_prediction_actions} WHERE predictionid IN (SELECT ap.id FROM {analytics_predictions} ap LEFT JOIN {context} ctx ON ap.contextid = ctx.id WHERE ctx.id IS NULL)"); // Cleanup analaytics predictions/calcs with MySQL friendly sub-select. $DB->execute("DELETE FROM {analytics_predictions} WHERE id IN ( SELECT oldpredictions.id FROM ( SELECT p.id FROM {analytics_predictions} p LEFT JOIN {context} ctx ON p.contextid = ctx.id WHERE ctx.id IS NULL ) oldpredictions )"); $DB->execute("DELETE FROM {analytics_indicator_calc} WHERE id IN ( SELECT oldcalcs.id FROM ( SELECT c.id FROM {analytics_indicator_calc} c LEFT JOIN {context} ctx ON c.contextid = ctx.id WHERE ctx.id IS NULL ) oldcalcs )"); // Clean up stuff that depends on analysable ids that do not exist anymore. $models = self::get_all_models(); foreach ($models as $model) { // We first dump into memory the list of analysables we have in the database (we could probably do this with 1 single // query for the 3 tables, but it may be safer to do it separately). $predictsamplesanalysableids = $DB->get_fieldset_select('analytics_predict_samples', 'DISTINCT analysableid', 'modelid = :modelid', ['modelid' => $model->get_id()]); $predictsamplesanalysableids = array_flip($predictsamplesanalysableids); $trainsamplesanalysableids = $DB->get_fieldset_select('analytics_train_samples', 'DISTINCT analysableid', 'modelid = :modelid', ['modelid' => $model->get_id()]); $trainsamplesanalysableids = array_flip($trainsamplesanalysableids); $usedanalysablesanalysableids = $DB->get_fieldset_select('analytics_used_analysables', 'DISTINCT analysableid', 'modelid = :modelid', ['modelid' => $model->get_id()]); $usedanalysablesanalysableids = array_flip($usedanalysablesanalysableids); $analyser = $model->get_analyser(array('notimesplitting' => true)); // We do not honour the list of contexts in this model as it can contain stale records. $analysables = $analyser->get_analysables_iterator(); $analysableids = []; foreach ($analysables as $analysable) { if (!$analysable) { continue; } unset($predictsamplesanalysableids[$analysable->get_id()]); unset($trainsamplesanalysableids[$analysable->get_id()]); unset($usedanalysablesanalysableids[$analysable->get_id()]); } $param = ['modelid' => $model->get_id()]; if ($predictsamplesanalysableids) { list($idssql, $idsparams) = $DB->get_in_or_equal(array_flip($predictsamplesanalysableids), SQL_PARAMS_NAMED); $DB->delete_records_select('analytics_predict_samples', "modelid = :modelid AND analysableid $idssql", $param + $idsparams); } if ($trainsamplesanalysableids) { list($idssql, $idsparams) = $DB->get_in_or_equal(array_flip($trainsamplesanalysableids), SQL_PARAMS_NAMED); $DB->delete_records_select('analytics_train_samples', "modelid = :modelid AND analysableid $idssql", $param + $idsparams); } if ($usedanalysablesanalysableids) { list($idssql, $idsparams) = $DB->get_in_or_equal(array_flip($usedanalysablesanalysableids), SQL_PARAMS_NAMED); $DB->delete_records_select('analytics_used_analysables', "modelid = :modelid AND analysableid $idssql", $param + $idsparams); } } // Clean up calculations table. $calclifetime = get_config('analytics', 'calclifetime'); if (!empty($calclifetime)) { $lifetime = time() - ($calclifetime * DAYSECS); // Value in days. $DB->delete_records_select('analytics_indicator_calc', 'timecreated < ?', [$lifetime]); } } /** * Default system backend. * * @return string */ public static function default_mlbackend() { return self::DEFAULT_MLBACKEND; } /** * Returns the provided element classes in the site. * * @param string $element * @return string[] Array keys are the FQCN and the values the class path. */ private static function get_analytics_classes($element) { // Just in case... $element = clean_param($element, PARAM_ALPHANUMEXT); $classes = \core_component::get_component_classes_in_namespace(null, 'analytics\\' . $element); return $classes; } /** * Check that all the models declared by the component are up to date. * * This is intended to be called during the installation / upgrade to automatically create missing models. * * @param string $componentname The name of the component to load models for. * @return array \core_analytics\model[] List of actually created models. */ public static function update_default_models_for_component(string $componentname): array { $result = []; foreach (static::load_default_models_for_component($componentname) as $definition) { if (!\core_analytics\model::exists(static::get_target($definition['target']))) { $result[] = static::create_declared_model($definition); } } return $result; } /** * Return the list of models declared by the given component. * * @param string $componentname The name of the component to load models for. * @throws \coding_exception Exception thrown in case of invalid syntax. * @return array The $models description array. */ public static function load_default_models_for_component(string $componentname): array { $dir = \core_component::get_component_directory($componentname); if (!$dir) { // This is either an invalid component, or a core subsystem without its own root directory. return []; } $file = $dir . '/' . self::ANALYTICS_FILENAME; if (!is_readable($file)) { return []; } $models = null; include($file); if (!isset($models) || !is_array($models) || empty($models)) { return []; } foreach ($models as &$model) { if (!isset($model['enabled'])) { $model['enabled'] = false; } else { $model['enabled'] = clean_param($model['enabled'], PARAM_BOOL); } // For the core models only, automatically remove references to modules that do not // exist. This allows you to install without error if there are missing plugins. if ($componentname === 'moodle') { $updatedindicators = []; $allmodules = []; foreach ($model['indicators'] as $indicator) { if (preg_match('~^\\\\mod_([^\\\\]+)\\\\~', $indicator, $matches)) { if (!$allmodules) { // The first time, get all modules. $allmodules = \core\plugin_manager::instance()->get_present_plugins('mod'); } if (!array_key_exists($matches[1], $allmodules)) { // Module does not exist, so skip indicator. continue; } } $updatedindicators[] = $indicator; } $model['indicators'] = $updatedindicators; } } static::validate_models_declaration($models); return $models; } /** * Return the list of all the models declared anywhere in this Moodle installation. * * Models defined by the core and core subsystems come first, followed by those provided by plugins. * * @return array indexed by the frankenstyle component */ public static function load_default_models_for_all_components(): array { $tmp = []; foreach (\core_component::get_component_list() as $type => $components) { foreach (array_keys($components) as $component) { if ($loaded = static::load_default_models_for_component($component)) { $tmp[$type][$component] = $loaded; } } } $result = []; if ($loaded = static::load_default_models_for_component('core')) { $result['core'] = $loaded; } if (!empty($tmp['core'])) { $result += $tmp['core']; unset($tmp['core']); } foreach ($tmp as $components) { $result += $components; } return $result; } /** * Validate the declaration of prediction models according the syntax expected in the component's db folder. * * The expected structure looks like this: * * [ * [ * 'target' => '\fully\qualified\name\of\the\target\class', * 'indicators' => [ * '\fully\qualified\name\of\the\first\indicator', * '\fully\qualified\name\of\the\second\indicator', * ], * 'timesplitting' => '\optional\name\of\the\time_splitting\class', * 'enabled' => true, * ], * ]; * * @param array $models List of declared models. * @throws \coding_exception Exception thrown in case of invalid syntax. */ public static function validate_models_declaration(array $models) { foreach ($models as $model) { if (!isset($model['target'])) { throw new \coding_exception('Missing target declaration'); } if (!static::is_valid($model['target'], '\core_analytics\local\target\base')) { throw new \coding_exception('Invalid target classname', $model['target']); } if (empty($model['indicators']) || !is_array($model['indicators'])) { throw new \coding_exception('Missing indicators declaration'); } foreach ($model['indicators'] as $indicator) { if (!static::is_valid($indicator, '\core_analytics\local\indicator\base')) { throw new \coding_exception('Invalid indicator classname', $indicator); } } if (isset($model['timesplitting'])) { if (substr($model['timesplitting'], 0, 1) !== '\\') { throw new \coding_exception('Expecting fully qualified time splitting classname', $model['timesplitting']); } if (!static::is_valid($model['timesplitting'], '\core_analytics\local\time_splitting\base')) { throw new \coding_exception('Invalid time splitting classname', $model['timesplitting']); } } if (!empty($model['enabled']) && !isset($model['timesplitting'])) { throw new \coding_exception('Cannot enable a model without time splitting method specified'); } } } /** * Create the defined model. * * @param array $definition See {@link self::validate_models_declaration()} for the syntax. * @return \core_analytics\model */ public static function create_declared_model(array $definition): \core_analytics\model { list($target, $indicators) = static::get_declared_target_and_indicators_instances($definition); if (isset($definition['timesplitting'])) { $timesplitting = $definition['timesplitting']; } else { $timesplitting = false; } $created = \core_analytics\model::create($target, $indicators, $timesplitting); if (!empty($definition['enabled'])) { $created->enable(); } return $created; } /** * Returns a string uniquely representing the given model declaration. * * @param array $model Model declaration * @return string complying with PARAM_ALPHANUM rules and starting with an 'id' prefix */ public static function model_declaration_identifier(array $model): string { return 'id'.sha1(serialize($model)); } /** * Given a model definition, return actual target and indicators instances. * * @param array $definition See {@link self::validate_models_declaration()} for the syntax. * @return array [0] => target instance, [1] => array of indicators instances */ public static function get_declared_target_and_indicators_instances(array $definition): array { $target = static::get_target($definition['target']); $indicators = []; foreach ($definition['indicators'] as $indicatorname) { $indicator = static::get_indicator($indicatorname); $indicators[$indicator->get_id()] = $indicator; } return [$target, $indicators]; } /** * Return the context restrictions that can be applied to the provided context levels. * * @throws \coding_exception * @param array|null $contextlevels The list of context levels provided by the analyser. Null if all of them. * @param string|null $query * @return array Associative array with contextid as key and the short version of the context name as value. */ public static function get_potential_context_restrictions(?array $contextlevels = null, ?string $query = null) { global $DB; if (empty($contextlevels) && !is_null($contextlevels)) { return false; } if (!is_null($contextlevels)) { foreach ($contextlevels as $contextlevel) { if ($contextlevel !== CONTEXT_COURSE && $contextlevel !== CONTEXT_COURSECAT) { throw new \coding_exception('Only CONTEXT_COURSE and CONTEXT_COURSECAT are supported at the moment.'); } } } $contexts = []; // We have a separate process for each context level for performance reasons (to iterate through mdl_context calling // get_context_name() would be too slow). $contextsystem = \context_system::instance(); if (is_null($contextlevels) || in_array(CONTEXT_COURSECAT, $contextlevels)) { $sql = "SELECT cc.id, cc.name, ctx.id AS contextid FROM {course_categories} cc JOIN {context} ctx ON ctx.contextlevel = :ctxlevel AND ctx.instanceid = cc.id"; $params = ['ctxlevel' => CONTEXT_COURSECAT]; if ($query) { $sql .= " WHERE " . $DB->sql_like('cc.name', ':query', false, false); $params['query'] = '%' . $query . '%'; } $coursecats = $DB->get_recordset_sql($sql, $params); foreach ($coursecats as $record) { $contexts[$record->contextid] = get_string('category') . ': ' . format_string($record->name, true, array('context' => $contextsystem)); } $coursecats->close(); } if (is_null($contextlevels) || in_array(CONTEXT_COURSE, $contextlevels)) { $sql = "SELECT c.id, c.shortname, ctx.id AS contextid FROM {course} c JOIN {context} ctx ON ctx.contextlevel = :ctxlevel AND ctx.instanceid = c.id WHERE c.id != :siteid"; $params = ['ctxlevel' => CONTEXT_COURSE, 'siteid' => SITEID]; if ($query) { $sql .= ' AND (' . $DB->sql_like('c.fullname', ':query1', false, false) . ' OR ' . $DB->sql_like('c.shortname', ':query2', false, false) . ')'; $params['query1'] = '%' . $query . '%'; $params['query2'] = '%' . $query . '%'; } $courses = $DB->get_recordset_sql($sql, $params); foreach ($courses as $record) { $contexts[$record->contextid] = get_string('course') . ': ' . format_string($record->shortname, true, array('context' => $contextsystem)); } $courses->close(); } return $contexts; } } classes/user.php 0000644 00000011475 15215712354 0007705 0 ustar 00 <?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; } } classes/dataset_manager.php 0000644 00000034436 15215712354 0012050 0 ustar 00 <?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; } } classes/prediction.php 0000644 00000017133 15215712354 0011064 0 ustar 00 <?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); } } classes/default_bulk_actions.php 0000644 00000010447 15215712354 0013106 0 ustar 00 <?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', ]; } } classes/model.php 0000644 00000216346 15215712354 0010033 0 ustar 00 <?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/>. /** * Prediction model representation. * * @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(); /** * Prediction model representation. * * @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 model { /** * All as expected. */ const OK = 0; /** * There was a problem. */ const GENERAL_ERROR = 1; /** * No dataset to analyse. */ const NO_DATASET = 2; /** * Model with low prediction accuracy. */ const LOW_SCORE = 4; /** * Not enough data to evaluate the model properly. */ const NOT_ENOUGH_DATA = 8; /** * Invalid analysable for the time splitting method. */ const ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD = 4; /** * Invalid analysable for all time splitting methods. */ const ANALYSABLE_STATUS_INVALID_FOR_RANGEPROCESSORS = 8; /** * Invalid analysable for the target */ const ANALYSABLE_STATUS_INVALID_FOR_TARGET = 16; /** * Minimum score to consider a non-static prediction model as good. */ const MIN_SCORE = 0.7; /** * Minimum prediction confidence (from 0 to 1) to accept a prediction as reliable enough. */ const PREDICTION_MIN_SCORE = 0.6; /** * Maximum standard deviation between different evaluation repetitions to consider that evaluation results are stable. */ const ACCEPTED_DEVIATION = 0.05; /** * Number of evaluation repetitions. */ const EVALUATION_ITERATIONS = 10; /** * @var \stdClass */ protected $model = null; /** * @var \core_analytics\local\analyser\base */ protected $analyser = null; /** * @var \core_analytics\local\target\base */ protected $target = null; /** * @var \core_analytics\predictor */ protected $predictionsprocessor = null; /** * @var \core_analytics\local\indicator\base[] */ protected $indicators = null; /** * @var \context[] */ protected $contexts = null; /** * Unique Model id created from site info and last model modification. * * @var string */ protected $uniqueid = null; /** * Constructor. * * @param int|\stdClass $model * @return void */ public function __construct($model) { global $DB; if (is_scalar($model)) { $model = $DB->get_record('analytics_models', array('id' => $model), '*', MUST_EXIST); if (!$model) { throw new \moodle_exception('errorunexistingmodel', 'analytics', '', $model); } } $this->model = $model; } /** * Quick safety check to discard site models which required components are not available anymore. * * @return bool */ public function is_available() { $target = $this->get_target(); if (!$target) { return false; } $classname = $target->get_analyser_class(); if (!class_exists($classname)) { return false; } return true; } /** * Returns the model id. * * @return int */ public function get_id() { return $this->model->id; } /** * Returns a plain \stdClass with the model data. * * @return \stdClass */ public function get_model_obj() { return $this->model; } /** * Returns the model target. * * @return \core_analytics\local\target\base */ public function get_target() { if ($this->target !== null) { return $this->target; } $instance = \core_analytics\manager::get_target($this->model->target); $this->target = $instance; return $this->target; } /** * Returns the model indicators. * * @return \core_analytics\local\indicator\base[] */ public function get_indicators() { if ($this->indicators !== null) { return $this->indicators; } $fullclassnames = json_decode($this->model->indicators); if (!is_array($fullclassnames)) { throw new \coding_exception('Model ' . $this->model->id . ' indicators can not be read'); } $this->indicators = array(); foreach ($fullclassnames as $fullclassname) { $instance = \core_analytics\manager::get_indicator($fullclassname); if ($instance) { $this->indicators[$fullclassname] = $instance; } else { debugging('Can\'t load ' . $fullclassname . ' indicator', DEBUG_DEVELOPER); } } return $this->indicators; } /** * Returns the list of indicators that could potentially be used by the model target. * * It includes the indicators that are part of the model. * * @return \core_analytics\local\indicator\base[] */ public function get_potential_indicators() { $indicators = \core_analytics\manager::get_all_indicators(); if (empty($this->analyser)) { $this->init_analyser(array('notimesplitting' => true)); } foreach ($indicators as $classname => $indicator) { if ($this->analyser->check_indicator_requirements($indicator) !== true) { unset($indicators[$classname]); } } return $indicators; } /** * Returns the model analyser (defined by the model target). * * @param array $options Default initialisation with no options. * @return \core_analytics\local\analyser\base */ public function get_analyser($options = array()) { if ($this->analyser !== null) { return $this->analyser; } $this->init_analyser($options); return $this->analyser; } /** * Initialises the model analyser. * * @throws \coding_exception * @param array $options * @return void */ protected function init_analyser($options = array()) { $target = $this->get_target(); $indicators = $this->get_indicators(); if (empty($target)) { throw new \moodle_exception('errornotarget', 'analytics'); } $potentialtimesplittings = $this->get_potential_timesplittings(); $timesplittings = array(); if (empty($options['notimesplitting'])) { if (!empty($options['evaluation'])) { // The evaluation process will run using all available time splitting methods unless one is specified. if (!empty($options['timesplitting'])) { $timesplitting = \core_analytics\manager::get_time_splitting($options['timesplitting']); if (empty($potentialtimesplittings[$timesplitting->get_id()])) { throw new \moodle_exception('errorcannotusetimesplitting', 'analytics'); } $timesplittings = array($timesplitting->get_id() => $timesplitting); } else { $timesplittingsforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(); // They both have the same objects, using $potentialtimesplittings as its items are sorted. $timesplittings = array_intersect_key($potentialtimesplittings, $timesplittingsforevaluation); } } else { if (empty($this->model->timesplitting)) { throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id); } // Returned as an array as all actions (evaluation, training and prediction) go through the same process. $timesplittings = array($this->model->timesplitting => $this->get_time_splitting()); } if (empty($timesplittings)) { throw new \moodle_exception('errornotimesplittings', 'analytics'); } } $classname = $target->get_analyser_class(); if (!class_exists($classname)) { throw new \coding_exception($classname . ' class does not exists'); } // Returns a \core_analytics\local\analyser\base class. $this->analyser = new $classname($this->model->id, $target, $indicators, $timesplittings, $options); } /** * Returns the model time splitting method. * * @return \core_analytics\local\time_splitting\base|false Returns false if no time splitting. */ public function get_time_splitting() { if (empty($this->model->timesplitting)) { return false; } return \core_analytics\manager::get_time_splitting($this->model->timesplitting); } /** * Returns the time-splitting methods that can be used by this model. * * @return \core_analytics\local\time_splitting\base[] */ public function get_potential_timesplittings() { $timesplittings = \core_analytics\manager::get_all_time_splittings(); uasort($timesplittings, function($a, $b) { return strcasecmp($a->get_name(), $b->get_name()); }); foreach ($timesplittings as $key => $timesplitting) { if (!$this->get_target()->can_use_timesplitting($timesplitting)) { unset($timesplittings[$key]); continue; } } return $timesplittings; } /** * Creates a new model. Enables it if $timesplittingid is specified. * * @param \core_analytics\local\target\base $target * @param \core_analytics\local\indicator\base[] $indicators * @param string|false $timesplittingid The time splitting method id (its fully qualified class name) * @param string|null $processor The machine learning backend this model will use. * @return \core_analytics\model */ public static function create(\core_analytics\local\target\base $target, array $indicators, $timesplittingid = false, $processor = null) { global $USER, $DB; $indicatorclasses = self::indicator_classes($indicators); $now = time(); $modelobj = new \stdClass(); $modelobj->target = $target->get_id(); $modelobj->indicators = json_encode($indicatorclasses); $modelobj->version = $now; $modelobj->timecreated = $now; $modelobj->timemodified = $now; $modelobj->usermodified = $USER->id; if ($target->based_on_assumptions()) { $modelobj->trained = 1; } if ($timesplittingid) { if (!\core_analytics\manager::is_valid($timesplittingid, '\core_analytics\local\time_splitting\base')) { throw new \moodle_exception('errorinvalidtimesplitting', 'analytics'); } if (substr($timesplittingid, 0, 1) !== '\\') { throw new \moodle_exception('errorinvalidtimesplitting', 'analytics'); } $modelobj->timesplitting = $timesplittingid; } if ($processor && !manager::is_valid($processor, '\core_analytics\classifier') && !manager::is_valid($processor, '\core_analytics\regressor')) { throw new \coding_exception('The provided predictions processor \\' . $processor . '\processor is not valid'); } else { $modelobj->predictionsprocessor = $processor; } $id = $DB->insert_record('analytics_models', $modelobj); // Get db defaults. $modelobj = $DB->get_record('analytics_models', array('id' => $id), '*', MUST_EXIST); $model = new static($modelobj); return $model; } /** * Does this model exist? * * If no indicators are provided it considers any model with the provided * target a match. * * @param \core_analytics\local\target\base $target * @param \core_analytics\local\indicator\base[]|false $indicators * @return bool */ public static function exists(\core_analytics\local\target\base $target, $indicators = false) { global $DB; $existingmodels = $DB->get_records('analytics_models', array('target' => $target->get_id())); if (!$existingmodels) { return false; } if (!$indicators && $existingmodels) { return true; } $indicatorids = array_keys($indicators); sort($indicatorids); foreach ($existingmodels as $modelobj) { $model = new \core_analytics\model($modelobj); $modelindicatorids = array_keys($model->get_indicators()); sort($modelindicatorids); if ($indicatorids === $modelindicatorids) { return true; } } return false; } /** * Updates the model. * * @param int|bool $enabled * @param \core_analytics\local\indicator\base[]|false $indicators False to respect current indicators * @param string|false $timesplittingid False to respect current time splitting method * @param string|false $predictionsprocessor False to respect current predictors processor value * @param int[]|false $contextids List of context ids for this model. False to respect the current list of contexts. * @return void */ public function update($enabled, $indicators = false, $timesplittingid = '', $predictionsprocessor = false, $contextids = false) { global $USER, $DB; \core_analytics\manager::check_can_manage_models(); $now = time(); if ($indicators !== false) { $indicatorclasses = self::indicator_classes($indicators); $indicatorsstr = json_encode($indicatorclasses); } else { // Respect current value. $indicatorsstr = $this->model->indicators; } if ($timesplittingid === false) { // Respect current value. $timesplittingid = $this->model->timesplitting; } if ($predictionsprocessor === false) { // Respect current value. $predictionsprocessor = $this->model->predictionsprocessor; } if ($contextids === false) { $contextsstr = $this->model->contextids; } else if (!$contextids) { $contextsstr = null; } else { $contextsstr = json_encode($contextids); // Reset the internal cache. $this->contexts = null; } if ($this->model->timesplitting !== $timesplittingid || $this->model->indicators !== $indicatorsstr || $this->model->predictionsprocessor !== $predictionsprocessor) { // Delete generated predictions before changing the model version. $this->clear(); // It needs to be reset as the version changes. $this->uniqueid = null; $this->indicators = null; // We update the version of the model so different time splittings are not mixed up. $this->model->version = $now; // Reset trained flag. if (!$this->is_static()) { $this->model->trained = 0; } } else if ($this->model->enabled != $enabled) { // We purge the cached contexts with insights as some will not be visible anymore. $this->purge_insights_cache(); } $this->model->enabled = intval($enabled); $this->model->indicators = $indicatorsstr; $this->model->timesplitting = $timesplittingid; $this->model->predictionsprocessor = $predictionsprocessor; $this->model->contextids = $contextsstr; $this->model->timemodified = $now; $this->model->usermodified = $USER->id; $DB->update_record('analytics_models', $this->model); } /** * Removes the model. * * @return void */ public function delete() { global $DB; \core_analytics\manager::check_can_manage_models(); $this->clear(); // Method self::clear is already clearing the current model version. $predictor = $this->get_predictions_processor(false); if ($predictor->is_ready() !== true) { $predictorname = \core_analytics\manager::get_predictions_processor_name($predictor); debugging('Prediction processor ' . $predictorname . ' is not ready to be used. Model ' . $this->model->id . ' could not be deleted.'); } else { $predictor->delete_output_dir($this->get_output_dir(array(), true), $this->get_unique_id()); } $DB->delete_records('analytics_models', array('id' => $this->model->id)); $DB->delete_records('analytics_models_log', array('modelid' => $this->model->id)); } /** * Evaluates the model. * * This method gets the site contents (through the analyser) creates a .csv dataset * with them and evaluates the model prediction accuracy multiple times using the * machine learning backend. It returns an object where the model score is the average * prediction accuracy of all executed evaluations. * * @param array $options * @return \stdClass[] */ public function evaluate($options = array()) { \core_analytics\manager::check_can_manage_models(); if ($this->is_static()) { $this->get_analyser()->add_log(get_string('noevaluationbasedassumptions', 'analytics')); $result = new \stdClass(); $result->status = self::NO_DATASET; return array($result); } $options['evaluation'] = true; if (empty($options['mode'])) { $options['mode'] = 'configuration'; } switch ($options['mode']) { case 'trainedmodel': // We are only interested on the time splitting method used by the trained model. $options['timesplitting'] = $this->model->timesplitting; // Provide the trained model directory to the ML backend if that is what we want to evaluate. $trainedmodeldir = $this->get_output_dir(['execution']); break; case 'configuration': $trainedmodeldir = false; break; default: throw new \moodle_exception('errorunknownaction', 'analytics'); } $this->init_analyser($options); if (empty($this->get_indicators())) { throw new \moodle_exception('errornoindicators', 'analytics'); } $this->heavy_duty_mode(); // Before get_labelled_data call so we get an early exception if it is not ready. $predictor = $this->get_predictions_processor(); $datasets = $this->get_analyser()->get_labelled_data($this->get_contexts()); // No datasets generated. if (empty($datasets)) { $result = new \stdClass(); $result->status = self::NO_DATASET; $result->info = $this->get_analyser()->get_logs(); return array($result); } if (!PHPUNIT_TEST && CLI_SCRIPT) { echo PHP_EOL . get_string('processingsitecontents', 'analytics') . PHP_EOL; } $results = array(); foreach ($datasets as $timesplittingid => $dataset) { $timesplitting = \core_analytics\manager::get_time_splitting($timesplittingid); $result = new \stdClass(); $dashestimesplittingid = str_replace('\\', '', $timesplittingid); $outputdir = $this->get_output_dir(array('evaluation', $dashestimesplittingid)); // Evaluate the dataset, the deviation we accept in the results depends on the amount of iterations. if ($this->get_target()->is_linear()) { $predictorresult = $predictor->evaluate_regression($this->get_unique_id(), self::ACCEPTED_DEVIATION, self::EVALUATION_ITERATIONS, $dataset, $outputdir, $trainedmodeldir); } else { $predictorresult = $predictor->evaluate_classification($this->get_unique_id(), self::ACCEPTED_DEVIATION, self::EVALUATION_ITERATIONS, $dataset, $outputdir, $trainedmodeldir); } $result->status = $predictorresult->status; $result->info = $predictorresult->info; if (isset($predictorresult->score)) { $result->score = $predictorresult->score; } else { // Prediction processors may return an error, default to 0 score in that case. $result->score = 0; } $dir = false; if (!empty($predictorresult->dir)) { $dir = $predictorresult->dir; } $result->logid = $this->log_result($timesplitting->get_id(), $result->score, $dir, $result->info, $options['mode']); $results[$timesplitting->get_id()] = $result; } return $results; } /** * Trains the model using the site contents. * * This method prepares a dataset from the site contents (through the analyser) * and passes it to the machine learning backends. Static models are skipped as * they do not require training. * * @return \stdClass */ public function train() { \core_analytics\manager::check_can_manage_models(); if ($this->is_static()) { $this->get_analyser()->add_log(get_string('notrainingbasedassumptions', 'analytics')); $result = new \stdClass(); $result->status = self::OK; return $result; } if (!$this->is_enabled() || empty($this->model->timesplitting)) { throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id); } if (empty($this->get_indicators())) { throw new \moodle_exception('errornoindicators', 'analytics'); } $this->heavy_duty_mode(); // Before get_labelled_data call so we get an early exception if it is not writable. $outputdir = $this->get_output_dir(array('execution')); // Before get_labelled_data call so we get an early exception if it is not ready. $predictor = $this->get_predictions_processor(); $datasets = $this->get_analyser()->get_labelled_data($this->get_contexts()); // No training if no files have been provided. if (empty($datasets) || empty($datasets[$this->model->timesplitting])) { $result = new \stdClass(); $result->status = self::NO_DATASET; $result->info = $this->get_analyser()->get_logs(); return $result; } $samplesfile = $datasets[$this->model->timesplitting]; // Train using the dataset. if ($this->get_target()->is_linear()) { $predictorresult = $predictor->train_regression($this->get_unique_id(), $samplesfile, $outputdir); } else { $predictorresult = $predictor->train_classification($this->get_unique_id(), $samplesfile, $outputdir); } $result = new \stdClass(); $result->status = $predictorresult->status; $result->info = $predictorresult->info; if ($result->status !== self::OK) { return $result; } $this->flag_file_as_used($samplesfile, 'trained'); // Mark the model as trained if it wasn't. if ($this->model->trained == false) { $this->mark_as_trained(); } return $result; } /** * Get predictions from the site contents. * * It analyses the site contents (through analyser classes) looking for samples * ready to receive predictions. It generates a dataset with all samples ready to * get predictions and it passes it to the machine learning backends or to the * targets based on assumptions to get the predictions. * * @return \stdClass */ public function predict() { global $DB; \core_analytics\manager::check_can_manage_models(); if (!$this->is_enabled() || empty($this->model->timesplitting)) { throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id); } if (empty($this->get_indicators())) { throw new \moodle_exception('errornoindicators', 'analytics'); } $this->heavy_duty_mode(); // Before get_unlabelled_data call so we get an early exception if it is not writable. $outputdir = $this->get_output_dir(array('execution')); if (!$this->is_static()) { // Predictions using a machine learning backend. // Before get_unlabelled_data call so we get an early exception if it is not ready. $predictor = $this->get_predictions_processor(); $samplesdata = $this->get_analyser()->get_unlabelled_data($this->get_contexts()); // Get the prediction samples file. if (empty($samplesdata) || empty($samplesdata[$this->model->timesplitting])) { $result = new \stdClass(); $result->status = self::NO_DATASET; $result->info = $this->get_analyser()->get_logs(); return $result; } $samplesfile = $samplesdata[$this->model->timesplitting]; // We need to throw an exception if we are trying to predict stuff that was already predicted. $params = array('modelid' => $this->model->id, 'action' => 'predicted', 'fileid' => $samplesfile->get_id()); if ($predicted = $DB->get_record('analytics_used_files', $params)) { throw new \moodle_exception('erroralreadypredict', 'analytics', '', $samplesfile->get_id()); } $indicatorcalculations = \core_analytics\dataset_manager::get_structured_data($samplesfile); // Estimation and classification processes run on the machine learning backend side. if ($this->get_target()->is_linear()) { $predictorresult = $predictor->estimate($this->get_unique_id(), $samplesfile, $outputdir); } else { $predictorresult = $predictor->classify($this->get_unique_id(), $samplesfile, $outputdir); } // Prepare the results object. $result = new \stdClass(); $result->status = $predictorresult->status; $result->info = $predictorresult->info; $result->predictions = $this->format_predictor_predictions($predictorresult); } else { // Predictions based on assumptions. $indicatorcalculations = $this->get_analyser()->get_static_data($this->get_contexts()); // Get the prediction samples file. if (empty($indicatorcalculations) || empty($indicatorcalculations[$this->model->timesplitting])) { $result = new \stdClass(); $result->status = self::NO_DATASET; $result->info = $this->get_analyser()->get_logs(); return $result; } // Same as reset($indicatorcalculations) as models based on assumptions only analyse 1 single // time-splitting method. $indicatorcalculations = $indicatorcalculations[$this->model->timesplitting]; // Prepare the results object. $result = new \stdClass(); $result->status = self::OK; $result->info = []; $result->predictions = $this->get_static_predictions($indicatorcalculations); } if ($result->status !== self::OK) { return $result; } if ($result->predictions) { list($samplecontexts, $predictionrecords) = $this->execute_prediction_callbacks($result->predictions, $indicatorcalculations); } if (!empty($samplecontexts) && $this->uses_insights()) { $this->trigger_insights($samplecontexts, $predictionrecords); } if (!$this->is_static()) { $this->flag_file_as_used($samplesfile, 'predicted'); } return $result; } /** * Returns the model predictions processor. * * @param bool $checkisready * @return \core_analytics\predictor */ public function get_predictions_processor($checkisready = true) { return manager::get_predictions_processor($this->model->predictionsprocessor, $checkisready); } /** * Formats the predictor results. * * @param array $predictorresult * @return array */ private function format_predictor_predictions($predictorresult) { $predictions = array(); if (!empty($predictorresult->predictions)) { foreach ($predictorresult->predictions as $sampleinfo) { // We parse each prediction. switch (count($sampleinfo)) { case 1: // For whatever reason the predictions processor could not process this sample, we // skip it and do nothing with it. debugging($this->model->id . ' model predictions processor could not process the sample with id ' . $sampleinfo[0], DEBUG_DEVELOPER); continue 2; case 2: // Prediction processors that do not return a prediction score will have the maximum prediction // score. list($uniquesampleid, $prediction) = $sampleinfo; $predictionscore = 1; break; case 3: list($uniquesampleid, $prediction, $predictionscore) = $sampleinfo; break; default: break; } $predictiondata = (object)['prediction' => $prediction, 'predictionscore' => $predictionscore]; $predictions[$uniquesampleid] = $predictiondata; } } return $predictions; } /** * Execute the prediction callbacks defined by the target. * * @param \stdClass[] $predictions * @param array $indicatorcalculations * @return array */ protected function execute_prediction_callbacks(&$predictions, $indicatorcalculations) { // Here we will store all predictions' contexts, this will be used to limit which users will see those predictions. $samplecontexts = array(); $records = array(); foreach ($predictions as $uniquesampleid => $prediction) { // The unique sample id contains both the sampleid and the rangeindex. list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid); if ($this->get_target()->triggers_callback($prediction->prediction, $prediction->predictionscore)) { // Prepare the record to store the predicted values. list($record, $samplecontext) = $this->prepare_prediction_record($sampleid, $rangeindex, $prediction->prediction, $prediction->predictionscore, json_encode($indicatorcalculations[$uniquesampleid])); // We will later bulk-insert them all. $records[$uniquesampleid] = $record; // Also store all samples context to later generate insights or whatever action the target wants to perform. $samplecontexts[$samplecontext->id] = $samplecontext; $this->get_target()->prediction_callback($this->model->id, $sampleid, $rangeindex, $samplecontext, $prediction->prediction, $prediction->predictionscore); } } if (!empty($records)) { $this->save_predictions($records); } return [$samplecontexts, $records]; } /** * Generates insights and updates the cache. * * @param \context[] $samplecontexts * @param \stdClass[] $predictionrecords * @return void */ protected function trigger_insights($samplecontexts, $predictionrecords) { // Notify the target that all predictions have been processed. if ($this->get_analyser()::one_sample_per_analysable()) { // We need to do something unusual here. self::save_predictions uses the bulk-insert function (insert_records()) for // performance reasons and that function does not return us the inserted ids. We need to retrieve them from // the database, and we need to do it using one single database query (for performance reasons as well). $predictionrecords = $this->add_prediction_ids($predictionrecords); $samplesdata = $this->predictions_sample_data($predictionrecords); $samplesdata = $this->append_calculations_info($predictionrecords, $samplesdata); $predictions = array_map(function($predictionobj) use ($samplesdata) { $prediction = new \core_analytics\prediction($predictionobj, $samplesdata[$predictionobj->sampleid]); return $prediction; }, $predictionrecords); } else { $predictions = []; } $this->get_target()->generate_insight_notifications($this->model->id, $samplecontexts, $predictions); if ($this->get_target()->link_insights_report()) { // Update cache. foreach ($samplecontexts as $context) { \core_analytics\manager::cached_models_with_insights($context, $this->get_id()); } } } /** * Get predictions from a static model. * * @param array $indicatorcalculations * @return \stdClass[] */ protected function get_static_predictions(&$indicatorcalculations) { $headers = array_shift($indicatorcalculations); // Get rid of the sampleid header. array_shift($headers); // Group samples by analysable for \core_analytics\local\target::calculate. $analysables = array(); // List all sampleids together. $sampleids = array(); foreach ($indicatorcalculations as $uniquesampleid => $indicators) { // Get rid of the sampleid column. unset($indicators[0]); $indicators = array_combine($headers, $indicators); $indicatorcalculations[$uniquesampleid] = $indicators; list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid); $analysable = $this->get_analyser()->get_sample_analysable($sampleid); $analysableclass = get_class($analysable); if (empty($analysables[$analysableclass])) { $analysables[$analysableclass] = array(); } if (empty($analysables[$analysableclass][$rangeindex])) { $analysables[$analysableclass][$rangeindex] = (object)[ 'analysable' => $analysable, 'indicatorsdata' => array(), 'sampleids' => array() ]; } // Using the sampleid as a key so we can easily merge indicators data later. $analysables[$analysableclass][$rangeindex]->indicatorsdata[$sampleid] = $indicators; // We could use indicatorsdata keys but the amount of redundant data is not that big and leaves code below cleaner. $analysables[$analysableclass][$rangeindex]->sampleids[$sampleid] = $sampleid; // Accumulate sample ids to get all their associated data in 1 single db query (analyser::get_samples). $sampleids[$sampleid] = $sampleid; } // Get all samples data. list($sampleids, $samplesdata) = $this->get_samples($sampleids); // Calculate the targets. $predictions = array(); foreach ($analysables as $analysableclass => $rangedata) { foreach ($rangedata as $rangeindex => $data) { // Attach samples data and calculated indicators data. $this->get_target()->clear_sample_data(); $this->get_target()->add_sample_data($samplesdata); $this->get_target()->add_sample_data($data->indicatorsdata); // Append new elements (we can not get duplicates because sample-analysable relation is N-1). $timesplitting = $this->get_time_splitting(); $timesplitting->set_modelid($this->get_id()); $timesplitting->set_analysable($data->analysable); $range = $timesplitting->get_range_by_index($rangeindex); $this->get_target()->filter_out_invalid_samples($data->sampleids, $data->analysable, false); $calculations = $this->get_target()->calculate($data->sampleids, $data->analysable, $range['start'], $range['end']); // Missing $indicatorcalculations values in $calculations are caused by is_valid_sample. We need to remove // these $uniquesampleid from $indicatorcalculations because otherwise they will be stored as calculated // by self::save_prediction. $indicatorcalculations = array_filter($indicatorcalculations, function($indicators, $uniquesampleid) use ($calculations, $rangeindex) { list($sampleid, $indicatorsrangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid); if ($rangeindex == $indicatorsrangeindex && !isset($calculations[$sampleid])) { return false; } return true; }, ARRAY_FILTER_USE_BOTH); foreach ($calculations as $sampleid => $value) { $uniquesampleid = $this->get_time_splitting()->append_rangeindex($sampleid, $rangeindex); // Null means that the target couldn't calculate the sample, we also remove them from $indicatorcalculations. if (is_null($calculations[$sampleid])) { unset($indicatorcalculations[$uniquesampleid]); continue; } // Even if static predictions are based on assumptions we flag them as 100% because they are 100% // true according to what the developer defined. $predictions[$uniquesampleid] = (object)['prediction' => $value, 'predictionscore' => 1]; } } } return $predictions; } /** * Stores the prediction in the database. * * @param int $sampleid * @param int $rangeindex * @param int $prediction * @param float $predictionscore * @param string $calculations * @return \context */ protected function prepare_prediction_record($sampleid, $rangeindex, $prediction, $predictionscore, $calculations) { $context = $this->get_analyser()->sample_access_context($sampleid); $record = new \stdClass(); $record->modelid = $this->model->id; $record->contextid = $context->id; $record->sampleid = $sampleid; $record->rangeindex = $rangeindex; $record->prediction = $prediction; $record->predictionscore = $predictionscore; $record->calculations = $calculations; $record->timecreated = time(); $analysable = $this->get_analyser()->get_sample_analysable($sampleid); $timesplitting = $this->get_time_splitting(); $timesplitting->set_modelid($this->get_id()); $timesplitting->set_analysable($analysable); $range = $timesplitting->get_range_by_index($rangeindex); if ($range) { $record->timestart = $range['start']; $record->timeend = $range['end']; } return array($record, $context); } /** * Save the prediction objects. * * @param \stdClass[] $records */ protected function save_predictions($records) { global $DB; $DB->insert_records('analytics_predictions', $records); } /** * Enabled the model using the provided time splitting method. * * @param string|false $timesplittingid False to respect the current time splitting method. * @return void */ public function enable($timesplittingid = false) { global $DB, $USER; $now = time(); if ($timesplittingid && $timesplittingid !== $this->model->timesplitting) { if (!\core_analytics\manager::is_valid($timesplittingid, '\core_analytics\local\time_splitting\base')) { throw new \moodle_exception('errorinvalidtimesplitting', 'analytics'); } if (substr($timesplittingid, 0, 1) !== '\\') { throw new \moodle_exception('errorinvalidtimesplitting', 'analytics'); } // Delete generated predictions before changing the model version. $this->clear(); // It needs to be reset as the version changes. $this->uniqueid = null; $this->model->timesplitting = $timesplittingid; $this->model->version = $now; // Reset trained flag. if (!$this->is_static()) { $this->model->trained = 0; } } else if (empty($this->model->timesplitting)) { // A valid timesplitting method needs to be supplied before a model can be enabled. throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id); } // Purge pages with insights as this may change things. if ($this->model->enabled != 1) { $this->purge_insights_cache(); } $this->model->enabled = 1; $this->model->timemodified = $now; $this->model->usermodified = $USER->id; // We don't always update timemodified intentionally as we reserve it for target, indicators or timesplitting updates. $DB->update_record('analytics_models', $this->model); } /** * Is this a static model (as defined by the target)?. * * Static models are based on assumptions instead of in machine learning * backends results. * * @return bool */ public function is_static() { return (bool)$this->get_target()->based_on_assumptions(); } /** * Is this model enabled? * * @return bool */ public function is_enabled() { return (bool)$this->model->enabled; } /** * Is this model already trained? * * @return bool */ public function is_trained() { // Models which targets are based on assumptions do not need training. return (bool)$this->model->trained || $this->is_static(); } /** * Marks the model as trained * * @return void */ public function mark_as_trained() { global $DB; \core_analytics\manager::check_can_manage_models(); $this->model->trained = 1; $DB->update_record('analytics_models', $this->model); } /** * Get the contexts with predictions. * * @param bool $skiphidden Skip hidden predictions * @return \stdClass[] */ public function get_predictions_contexts($skiphidden = true) { global $DB, $USER; $sql = "SELECT DISTINCT ap.contextid FROM {analytics_predictions} ap JOIN {context} ctx ON ctx.id = ap.contextid WHERE ap.modelid = :modelid"; $params = array('modelid' => $this->model->id); if ($skiphidden) { $sql .= " AND NOT EXISTS ( SELECT 1 FROM {analytics_prediction_actions} apa WHERE apa.predictionid = ap.id AND apa.userid = :userid AND (apa.actionname = :fixed OR apa.actionname = :notuseful OR apa.actionname = :useful OR apa.actionname = :notapplicable OR apa.actionname = :incorrectlyflagged) )"; $params['userid'] = $USER->id; $params['fixed'] = \core_analytics\prediction::ACTION_FIXED; $params['notuseful'] = \core_analytics\prediction::ACTION_NOT_USEFUL; $params['useful'] = \core_analytics\prediction::ACTION_USEFUL; $params['notapplicable'] = \core_analytics\prediction::ACTION_NOT_APPLICABLE; $params['incorrectlyflagged'] = \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED; } return $DB->get_records_sql($sql, $params); } /** * Has this model generated predictions? * * We don't check analytics_predictions table because targets have the ability to * ignore some predicted values, if that is the case predictions are not even stored * in db. * * @return bool */ public function any_prediction_obtained() { global $DB; return $DB->record_exists('analytics_predict_samples', array('modelid' => $this->model->id, 'timesplitting' => $this->model->timesplitting)); } /** * Whether this model generates insights or not (defined by the model's target). * * @return bool */ public function uses_insights() { $target = $this->get_target(); return $target::uses_insights(); } /** * Whether predictions exist for this context. * * @param \context $context * @return bool */ public function predictions_exist(\context $context) { global $DB; // Filters out previous predictions keeping only the last time range one. $select = "modelid = :modelid AND contextid = :contextid"; $params = array('modelid' => $this->model->id, 'contextid' => $context->id); return $DB->record_exists_select('analytics_predictions', $select, $params); } /** * Gets the predictions for this context. * * @param \context $context * @param bool $skiphidden Skip hidden predictions * @param int $page The page of results to fetch. False for all results. * @param int $perpage The max number of results to fetch. Ignored if $page is false. * @return array($total, \core_analytics\prediction[]) */ public function get_predictions(\context $context, $skiphidden = true, $page = false, $perpage = 100) { global $DB, $USER; \core_analytics\manager::check_can_list_insights($context); // Filters out previous predictions keeping only the last time range one. $sql = "SELECT ap.* FROM {analytics_predictions} ap JOIN ( SELECT sampleid, max(rangeindex) AS rangeindex FROM {analytics_predictions} WHERE modelid = :modelidsubap and contextid = :contextidsubap GROUP BY sampleid ) apsub ON ap.sampleid = apsub.sampleid AND ap.rangeindex = apsub.rangeindex WHERE ap.modelid = :modelid and ap.contextid = :contextid"; $params = array('modelid' => $this->model->id, 'contextid' => $context->id, 'modelidsubap' => $this->model->id, 'contextidsubap' => $context->id); if ($skiphidden) { $sql .= " AND NOT EXISTS ( SELECT 1 FROM {analytics_prediction_actions} apa WHERE apa.predictionid = ap.id AND apa.userid = :userid AND (apa.actionname = :fixed OR apa.actionname = :notuseful OR apa.actionname = :useful OR apa.actionname = :notapplicable OR apa.actionname = :incorrectlyflagged) )"; $params['userid'] = $USER->id; $params['fixed'] = \core_analytics\prediction::ACTION_FIXED; $params['notuseful'] = \core_analytics\prediction::ACTION_NOT_USEFUL; $params['useful'] = \core_analytics\prediction::ACTION_USEFUL; $params['notapplicable'] = \core_analytics\prediction::ACTION_NOT_APPLICABLE; $params['incorrectlyflagged'] = \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED; } $sql .= " ORDER BY ap.timecreated DESC"; if (!$predictions = $DB->get_records_sql($sql, $params)) { return array(); } // Get predicted samples' ids. $sampleids = array_map(function($prediction) { return $prediction->sampleid; }, $predictions); list($unused, $samplesdata) = $this->get_samples($sampleids); $current = 0; if ($page !== false) { $offset = $page * $perpage; $limit = $offset + $perpage; } foreach ($predictions as $predictionid => $predictiondata) { $sampleid = $predictiondata->sampleid; // Filter out predictions which samples are not available anymore. if (empty($samplesdata[$sampleid])) { unset($predictions[$predictionid]); continue; } // Return paginated dataset - we cannot paginate in the DB because we post filter the list. if ($page === false || ($current >= $offset && $current < $limit)) { // Replace \stdClass object by \core_analytics\prediction objects. $prediction = new \core_analytics\prediction($predictiondata, $samplesdata[$sampleid]); $predictions[$predictionid] = $prediction; } else { unset($predictions[$predictionid]); } $current++; } if (empty($predictions)) { return array(); } return [$current, $predictions]; } /** * Returns the actions executed by users on the predictions. * * @param \context|null $context * @return \moodle_recordset */ public function get_prediction_actions(?\context $context): \moodle_recordset { global $DB; $sql = "SELECT apa.id, apa.predictionid, apa.userid, apa.actionname, apa.timecreated, ap.contextid, ap.sampleid, ap.rangeindex, ap.prediction, ap.predictionscore FROM {analytics_prediction_actions} apa JOIN {analytics_predictions} ap ON ap.id = apa.predictionid WHERE ap.modelid = :modelid"; $params = ['modelid' => $this->model->id]; if ($context) { $sql .= " AND ap.contextid = :contextid"; $params['contextid'] = $context->id; } return $DB->get_recordset_sql($sql, $params); } /** * Returns the sample data of a prediction. * * @param \stdClass $predictionobj * @return array */ public function prediction_sample_data($predictionobj) { list($unused, $samplesdata) = $this->get_samples(array($predictionobj->sampleid)); if (empty($samplesdata[$predictionobj->sampleid])) { throw new \moodle_exception('errorsamplenotavailable', 'analytics'); } return $samplesdata[$predictionobj->sampleid]; } /** * Returns the samples data of the provided predictions. * * @param \stdClass[] $predictionrecords * @return array */ public function predictions_sample_data(array $predictionrecords): array { $sampleids = []; foreach ($predictionrecords as $predictionobj) { $sampleids[] = $predictionobj->sampleid; } list($sampleids, $samplesdata) = $this->get_analyser()->get_samples($sampleids); return $samplesdata; } /** * Appends the calculation info to the samples data. * * @param \stdClass[] $predictionrecords * @param array $samplesdata * @return array */ public function append_calculations_info(array $predictionrecords, array $samplesdata): array { if ($extrainfo = calculation_info::pull_info($predictionrecords)) { foreach ($samplesdata as $sampleid => $data) { // The extra info come prefixed by extra: so we will not have overwrites here. $samplesdata[$sampleid] = $samplesdata[$sampleid] + $extrainfo[$sampleid]; } } return $samplesdata; } /** * Returns the description of a sample * * @param \core_analytics\prediction $prediction * @return array 2 elements: list(string, \renderable) */ public function prediction_sample_description(\core_analytics\prediction $prediction) { return $this->get_analyser()->sample_description($prediction->get_prediction_data()->sampleid, $prediction->get_prediction_data()->contextid, $prediction->get_sample_data()); } /** * Returns the default output directory for prediction processors * * @return string */ public static function default_output_dir(): string { global $CFG; return $CFG->dataroot . DIRECTORY_SEPARATOR . 'models'; } /** * Returns the output directory for prediction processors. * * Directory structure as follows: * - Evaluation runs: * models/$model->id/$model->version/evaluation/$model->timesplitting * - Training & prediction runs: * models/$model->id/$model->version/execution * * @param array $subdirs * @param bool $onlymodelid Preference over $subdirs * @return string */ public function get_output_dir($subdirs = array(), $onlymodelid = false) { $subdirstr = ''; foreach ($subdirs as $subdir) { $subdirstr .= DIRECTORY_SEPARATOR . $subdir; } $outputdir = get_config('analytics', 'modeloutputdir'); if (empty($outputdir)) { // Apply default value. $outputdir = self::default_output_dir(); } // Append model id. $outputdir .= DIRECTORY_SEPARATOR . $this->model->id; if (!$onlymodelid) { // Append version + subdirs. $outputdir .= DIRECTORY_SEPARATOR . $this->model->version . $subdirstr; } make_writable_directory($outputdir); return $outputdir; } /** * Returns a unique id for this model. * * This id should be unique for this site. * * @return string */ public function get_unique_id() { global $CFG; if (!is_null($this->uniqueid)) { return $this->uniqueid; } // Generate a unique id for this site, this model and this time splitting method, considering the last time // that the model target and indicators were updated. $ids = array($CFG->wwwroot, $CFG->prefix, $this->model->id, $this->model->version); $this->uniqueid = sha1(implode('$$', $ids)); return $this->uniqueid; } /** * Exports the model data for displaying it in a template. * * @param \renderer_base $output The renderer to use for exporting * @return \stdClass */ public function export(\renderer_base $output) { \core_analytics\manager::check_can_manage_models(); $data = clone $this->model; $data->modelname = format_string($this->get_name()); $data->name = $this->inplace_editable_name()->export_for_template($output); $data->target = $this->get_target()->get_name(); $data->targetclass = $this->get_target()->get_id(); if ($timesplitting = $this->get_time_splitting()) { $data->timesplitting = $timesplitting->get_name(); } $data->indicators = array(); foreach ($this->get_indicators() as $indicator) { $data->indicators[] = $indicator->get_name(); } return $data; } /** * Exports the model data to a zip file. * * @param string $zipfilename * @param bool $includeweights Include the model weights if available * @return string Zip file path */ public function export_model(string $zipfilename, bool $includeweights = true): string { \core_analytics\manager::check_can_manage_models(); $modelconfig = new model_config($this); return $modelconfig->export($zipfilename, $includeweights); } /** * Imports the provided model. * * Note that this method assumes that model_config::check_dependencies has already been called. * * @throws \moodle_exception * @param string $zipfilepath Zip file path * @return \core_analytics\model */ public static function import_model(string $zipfilepath): \core_analytics\model { \core_analytics\manager::check_can_manage_models(); $modelconfig = new \core_analytics\model_config(); return $modelconfig->import($zipfilepath); } /** * Can this model be exported? * * @return bool */ public function can_export_configuration(): bool { if (empty($this->model->timesplitting)) { return false; } if (!$this->get_indicators()) { return false; } if ($this->is_static()) { return false; } return true; } /** * Returns the model logs data. * * @param int $limitfrom * @param int $limitnum * @return \stdClass[] */ public function get_logs($limitfrom = 0, $limitnum = 0) { global $DB; \core_analytics\manager::check_can_manage_models(); return $DB->get_records('analytics_models_log', array('modelid' => $this->get_id()), 'timecreated DESC', '*', $limitfrom, $limitnum); } /** * Merges all training data files into one and returns it. * * @return \stored_file|false */ public function get_training_data() { \core_analytics\manager::check_can_manage_models(); $timesplittingid = $this->get_time_splitting()->get_id(); return \core_analytics\dataset_manager::export_training_data($this->get_id(), $timesplittingid); } /** * Has the model been trained using data from this site? * * This method is useful to determine if a trained model can be evaluated as * we can not use the same data for training and for evaluation. * * @return bool */ public function trained_locally(): bool { global $DB; if (!$this->is_trained() || $this->is_static()) { // Early exit. return false; } if ($DB->record_exists('analytics_train_samples', ['modelid' => $this->model->id])) { return true; } return false; } /** * Flag the provided file as used for training or prediction. * * @param \stored_file $file * @param string $action * @return void */ protected function flag_file_as_used(\stored_file $file, $action) { global $DB; $usedfile = new \stdClass(); $usedfile->modelid = $this->model->id; $usedfile->fileid = $file->get_id(); $usedfile->action = $action; $usedfile->time = time(); $DB->insert_record('analytics_used_files', $usedfile); } /** * Log the evaluation results in the database. * * @param string $timesplittingid * @param float $score * @param string $dir * @param array $info * @param string $evaluationmode * @return int The inserted log id */ protected function log_result($timesplittingid, $score, $dir = false, $info = false, $evaluationmode = 'configuration') { global $DB, $USER; $log = new \stdClass(); $log->modelid = $this->get_id(); $log->version = $this->model->version; $log->evaluationmode = $evaluationmode; $log->target = $this->model->target; $log->indicators = $this->model->indicators; $log->timesplitting = $timesplittingid; $log->dir = $dir; if ($info) { // Ensure it is not an associative array. $log->info = json_encode(array_values($info)); } $log->score = $score; $log->timecreated = time(); $log->usermodified = $USER->id; return $DB->insert_record('analytics_models_log', $log); } /** * Utility method to return indicator class names from a list of indicator objects * * @param \core_analytics\local\indicator\base[] $indicators * @return string[] */ private static function indicator_classes($indicators) { // What we want to check and store are the indicator classes not the keys. $indicatorclasses = array(); foreach ($indicators as $indicator) { if (!\core_analytics\manager::is_valid($indicator, '\core_analytics\local\indicator\base')) { if (!is_object($indicator) && !is_scalar($indicator)) { $indicator = strval($indicator); } else if (is_object($indicator)) { $indicator = '\\' . get_class($indicator); } throw new \moodle_exception('errorinvalidindicator', 'analytics', '', $indicator); } $indicatorclasses[] = $indicator->get_id(); } return $indicatorclasses; } /** * Clears the model training and prediction data. * * Executed after updating model critical elements like the time splitting method * or the indicators. * * @return void */ public function clear() { global $DB, $USER; \core_analytics\manager::check_can_manage_models(); // Delete current model version stored stuff. $predictor = $this->get_predictions_processor(false); if ($predictor->is_ready() !== true) { $predictorname = \core_analytics\manager::get_predictions_processor_name($predictor); debugging('Prediction processor ' . $predictorname . ' is not ready to be used. Model ' . $this->model->id . ' could not be cleared.'); } else { $predictor->clear_model($this->get_unique_id(), $this->get_output_dir()); } $DB->delete_records_select('analytics_prediction_actions', "predictionid IN (SELECT id FROM {analytics_predictions} WHERE modelid = :modelid)", ['modelid' => $this->get_id()]); $DB->delete_records('analytics_predictions', array('modelid' => $this->model->id)); $DB->delete_records('analytics_predict_samples', array('modelid' => $this->model->id)); $DB->delete_records('analytics_train_samples', array('modelid' => $this->model->id)); $DB->delete_records('analytics_used_files', array('modelid' => $this->model->id)); $DB->delete_records('analytics_used_analysables', array('modelid' => $this->model->id)); // Purge all generated files. \core_analytics\dataset_manager::clear_model_files($this->model->id); // We don't expect people to clear models regularly and the cost of filling the cache is // 1 db read per context. $this->purge_insights_cache(); if (!$this->is_static()) { $this->model->trained = 0; } $this->model->timemodified = time(); $this->model->usermodified = $USER->id; $DB->update_record('analytics_models', $this->model); } /** * Returns the name of the model. * * By default, models use their target's name as their own name. They can have their explicit name, too. In which * case, the explicit name is used instead of the default one. * * @return string|lang_string */ public function get_name() { if (trim($this->model->name ?? '') === '') { return $this->get_target()->get_name(); } else { return $this->model->name; } } /** * Renames the model to the given name. * * When given an empty string, the model falls back to using the associated target's name as its name. * * @param string $name The new name for the model, empty string for using the default name. */ public function rename(string $name) { global $DB, $USER; $this->model->name = $name; $this->model->timemodified = time(); $this->model->usermodified = $USER->id; $DB->update_record('analytics_models', $this->model); } /** * Returns an inplace editable element with the model's name. * * @return \core\output\inplace_editable */ public function inplace_editable_name() { $displayname = format_string($this->get_name()); return new \core\output\inplace_editable('core_analytics', 'modelname', $this->model->id, has_capability('moodle/analytics:managemodels', \context_system::instance()), $displayname, $this->model->name); } /** * Returns true if the time-splitting method used by this model is invalid for this model. * @return bool */ public function invalid_timesplitting_selected(): bool { $currenttimesplitting = $this->model->timesplitting; if (empty($currenttimesplitting)) { // Not set is different from invalid. This function is used to identify invalid // time-splittings. return false; } $potentialtimesplittings = $this->get_potential_timesplittings(); if ($currenttimesplitting && empty($potentialtimesplittings[$currenttimesplitting])) { return true; } return false; } /** * Adds the id from {analytics_predictions} db table to the prediction \stdClass objects. * * @param \stdClass[] $predictionrecords * @return \stdClass[] The prediction records including their ids in {analytics_predictions} db table. */ private function add_prediction_ids($predictionrecords) { global $DB; $firstprediction = reset($predictionrecords); $contextids = array_map(function($predictionobj) { return $predictionobj->contextid; }, $predictionrecords); // Limited to 30000 records as a middle point between the ~65000 params limit in pgsql and the size limit for mysql which // can be increased if required up to a reasonable point. $chunks = array_chunk($contextids, 30000); foreach ($chunks as $contextidschunk) { list($contextsql, $contextparams) = $DB->get_in_or_equal($contextidschunk, SQL_PARAMS_NAMED); // We select the fields that will allow us to map ids to $predictionrecords. Given that we already filter by modelid // we have enough with sampleid and rangeindex. The reason is that the sampleid relation to a site is N - 1. $fields = 'id, sampleid, rangeindex'; // We include the contextid and the timecreated filter to reduce the number of records in $dbpredictions. We can not // add as many OR conditions as records in $predictionrecords. $sql = "SELECT $fields FROM {analytics_predictions} WHERE modelid = :modelid AND contextid $contextsql AND timecreated >= :firsttimecreated"; $params = $contextparams + ['modelid' => $this->model->id, 'firsttimecreated' => $firstprediction->timecreated]; $dbpredictions = $DB->get_recordset_sql($sql, $params); foreach ($dbpredictions as $id => $dbprediction) { // The append_rangeindex implementation is the same regardless of the time splitting method in use. $uniqueid = $this->get_time_splitting()->append_rangeindex($dbprediction->sampleid, $dbprediction->rangeindex); $predictionrecords[$uniqueid]->id = $dbprediction->id; } $dbpredictions->close(); } return $predictionrecords; } /** * Wrapper around analyser's get_samples to skip DB's max-number-of-params exception. * * @param array $sampleids * @return array */ public function get_samples(array $sampleids): array { if (empty($sampleids)) { throw new \coding_exception('No sample ids provided'); } $chunksize = count($sampleids); // We start with just 1 chunk, if it is too large for the db we split the list of sampleids in 2 and we // try again. We repeat this process until the chunk is small enough for the db engine to process. The // >= has been added in case there are other \dml_read_exceptions unrelated to the max number of params. while (empty($done) && $chunksize >= 1) { $chunks = array_chunk($sampleids, $chunksize); $allsampleids = []; $allsamplesdata = []; foreach ($chunks as $index => $chunk) { try { list($chunksampleids, $chunksamplesdata) = $this->get_analyser()->get_samples($chunk); } catch (\dml_read_exception $e) { // Reduce the chunksize, we use floor() so the $chunksize is always less than the previous $chunksize value. $chunksize = floor($chunksize / 2); break; } // We can sum as these two arrays are indexed by sampleid and there are no collisions. $allsampleids = $allsampleids + $chunksampleids; $allsamplesdata = $allsamplesdata + $chunksamplesdata; if ($index === count($chunks) - 1) { // We successfully processed all the samples in all chunks, we are done. $done = true; } } } if (empty($done)) { if (!empty($e)) { // Throw the last exception we caught, the \dml_read_exception we have been catching is unrelated to the max number // of param's exception. throw new \dml_read_exception($e); } else { throw new \coding_exception('We should never reach this point, there is a bug in ' . 'core_analytics\\model::get_samples\'s code'); } } return [$allsampleids, $allsamplesdata]; } /** * Contexts where this model should be active. * * @return \context[] Empty array if there are no context restrictions. */ public function get_contexts() { if ($this->contexts !== null) { return $this->contexts; } if (!$this->model->contextids) { $this->contexts = []; return $this->contexts; } $contextids = json_decode($this->model->contextids); // We don't expect this list to be massive as contexts need to be selected manually using the edit model form. $this->contexts = array_map(function($contextid) { return \context::instance_by_id($contextid, IGNORE_MISSING); }, $contextids); return $this->contexts; } /** * Purges the insights cache. */ private function purge_insights_cache() { $cache = \cache::make('core', 'contextwithinsights'); $cache->purge(); } /** * Increases system memory and time limits. * * @return void */ private function heavy_duty_mode() { if (ini_get('memory_limit') != -1) { raise_memory_limit(MEMORY_HUGE); } \core_php_time_limit::raise(); } } classes/bulk_action.php 0000644 00000005062 15215712354 0011214 0 ustar 00 <?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); } } classes/course.php 0000644 00000043434 15215712354 0010227 0 ustar 00 <?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); } } classes/insights_generator.php 0000644 00000023445 15215712354 0012625 0 ustar 00 <?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); } } classes/local/analyser/base.php 0000644 00000043213 15215712354 0012544 0 ustar 00 <?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; } } classes/local/analyser/sitewide.php 0000644 00000003346 15215712354 0013452 0 ustar 00 <?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()]); } } classes/local/analyser/by_course.php 0000644 00000005120 15215712354 0013617 0 ustar 00 <?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]; } } classes/local/indicator/base.php 0000644 00000014414 15215712354 0012703 0 ustar 00 <?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; } } classes/local/indicator/binary.php 0000644 00000007046 15215712354 0013260 0 ustar 00 <?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; } } classes/local/indicator/community_of_inquiry_activity.php 0000644 00000106420 15215712354 0020174 0 ustar 00 <?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/>. /** * Community of inquiry abstract 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(); /** * Community of inquire abstract 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 community_of_inquiry_activity extends linear { /** * instancedata * * @var array */ protected $instancedata = array(); /** * @var \core_analytics\course */ protected $course = null; /** * @var array Array of logs by [contextid][userid] */ protected $activitylogs = null; /** * @var array Array of grades by [contextid][userid] */ protected $grades = null; /** * Constant cognitive indicator type. */ const INDICATOR_COGNITIVE = "cognitve"; /** * Constant social indicator type. */ const INDICATOR_SOCIAL = "social"; /** * Constant for this cognitive level. */ const COGNITIVE_LEVEL_1 = 1; /** * Constant for this cognitive level. */ const COGNITIVE_LEVEL_2 = 2; /** * Constant for this cognitive level. */ const COGNITIVE_LEVEL_3 = 3; /** * Constant for this cognitive level. */ const COGNITIVE_LEVEL_4 = 4; /** * Constant for this cognitive level. */ const COGNITIVE_LEVEL_5 = 5; /** * Constant for this social level. */ const SOCIAL_LEVEL_1 = 1; /** * Constant for this social level. */ const SOCIAL_LEVEL_2 = 2; /** * Constant for this social level. */ const SOCIAL_LEVEL_3 = 3; /** * Constant for this social level. */ const SOCIAL_LEVEL_4 = 4; /** * Constant for this social level. */ const SOCIAL_LEVEL_5 = 5; /** * Max cognitive depth level accepted. */ const MAX_COGNITIVE_LEVEL = 5; /** * Max social breadth level accepted. */ const MAX_SOCIAL_LEVEL = 5; /** * Fetch the course grades of this activity type instances. * * @param \core_analytics\analysable $analysable * @return void */ public function fill_per_analysable_caches(\core_analytics\analysable $analysable) { // Better to check it, we can not be 100% it will be a \core_analytics\course object. if ($analysable instanceof \core_analytics\course) { $this->fetch_student_grades($analysable); } } /** * Returns the activity type. No point in changing this class in children classes. * * @var string The activity name (e.g. assign or quiz) */ final public function get_activity_type() { $class = get_class($this); $package = stristr($class, "\\", true); $type = str_replace("mod_", "", $package); if ($type === $package) { throw new \coding_exception("$class does not belong to any module specific namespace"); } return $type; } /** * Returns the potential level of cognitive depth. * * @param \cm_info $cm * @return int */ public function get_cognitive_depth_level(\cm_info $cm) { throw new \coding_exception('Overwrite get_cognitive_depth_level method to set your activity potential cognitive ' . 'depth level'); } /** * Returns the potential level of social breadth. * * @param \cm_info $cm * @return int */ public function get_social_breadth_level(\cm_info $cm) { throw new \coding_exception('Overwrite get_social_breadth_level method to set your activity potential social ' . 'breadth level'); } /** * required_sample_data * * @return string[] */ public static function required_sample_data() { // Only course because the indicator is valid even without students. return array('course'); } /** * Do activity logs contain any log of user in this context? * * If user is empty we look for any log in this context. * * @param int $contextid * @param \stdClass|false $user * @return bool */ final protected function any_log($contextid, $user) { if (empty($this->activitylogs[$contextid])) { return false; } // Someone interacted with the activity if there is no user or the user interacted with the // activity if there is a user. if (empty($user) || (!empty($user) && !empty($this->activitylogs[$contextid][$user->id]))) { return true; } return false; } /** * Do activity logs contain any write log of user in this context? * * If user is empty we look for any write log in this context. * * @param int $contextid * @param \stdClass|false $user * @return bool */ final protected function any_write_log($contextid, $user) { if (empty($this->activitylogs[$contextid])) { return false; } // No specific user, we look at all activity logs. $it = $this->activitylogs[$contextid]; if ($user) { if (empty($this->activitylogs[$contextid][$user->id])) { return false; } $it = array($user->id => $this->activitylogs[$contextid][$user->id]); } foreach ($it as $events) { foreach ($events as $log) { if ($log->crud === 'c' || $log->crud === 'u') { return true; } } } return false; } /** * Is there any feedback activity log for this user in this context? * * This method returns true if $user is empty and there is any feedback activity logs. * * @param string $action * @param \cm_info $cm * @param int $contextid * @param \stdClass|false $user * @return bool */ protected function any_feedback($action, \cm_info $cm, $contextid, $user) { if (!in_array($action, ['submitted', 'replied', 'viewed'])) { throw new \coding_exception('Provided action "' . $action . '" is not valid.'); } if (empty($this->activitylogs[$contextid])) { return false; } if (empty($this->grades[$contextid]) && $this->feedback_check_grades()) { // If there are no grades there is no feedback. return false; } $it = $this->activitylogs[$contextid]; if ($user) { if (empty($this->activitylogs[$contextid][$user->id])) { return false; } $it = array($user->id => $this->activitylogs[$contextid][$user->id]); } foreach ($this->activitylogs[$contextid] as $userid => $events) { $methodname = 'feedback_' . $action; if ($this->{$methodname}($cm, $contextid, $userid)) { return true; } // If it wasn't viewed try with the next user. } return false; } /** * $cm is used for this method overrides. * * This function must be fast. * * @param \cm_info $cm * @param mixed $contextid * @param mixed $userid * @param int $after Timestamp, defaults to the graded date or false if we don't check the date. * @return bool */ protected function feedback_viewed(\cm_info $cm, $contextid, $userid, $after = null) { return $this->feedback_post_action($cm, $contextid, $userid, $this->feedback_viewed_events(), $after); } /** * $cm is used for this method overrides. * * This function must be fast. * * @param \cm_info $cm * @param mixed $contextid * @param mixed $userid * @param int $after Timestamp, defaults to the graded date or false if we don't check the date. * @return bool */ protected function feedback_replied(\cm_info $cm, $contextid, $userid, $after = null) { return $this->feedback_post_action($cm, $contextid, $userid, $this->feedback_replied_events(), $after); } /** * $cm is used for this method overrides. * * This function must be fast. * * @param \cm_info $cm * @param mixed $contextid * @param mixed $userid * @param int $after Timestamp, defaults to the graded date or false if we don't check the date. * @return bool */ protected function feedback_submitted(\cm_info $cm, $contextid, $userid, $after = null) { return $this->feedback_post_action($cm, $contextid, $userid, $this->feedback_submitted_events(), $after); } /** * Returns the list of events that involve viewing feedback from other users. * * @return string[] */ protected function feedback_viewed_events() { throw new \coding_exception('Activities with a potential cognitive or social level that include viewing feedback ' . 'should define "feedback_viewed_events" method or should override feedback_viewed method.'); } /** * Returns the list of events that involve replying to feedback from other users. * * @return string[] */ protected function feedback_replied_events() { throw new \coding_exception('Activities with a potential cognitive or social level that include replying to feedback ' . 'should define "feedback_replied_events" method or should override feedback_replied method.'); } /** * Returns the list of events that involve submitting something after receiving feedback from other users. * * @return string[] */ protected function feedback_submitted_events() { throw new \coding_exception('Activities with a potential cognitive or social level that include viewing feedback ' . 'should define "feedback_submitted_events" method or should override feedback_submitted method.'); } /** * Whether this user in this context did any of the provided actions (events) * * @param \cm_info $cm * @param int $contextid * @param int $userid * @param string[] $eventnames * @param int|false $after * @return bool */ protected function feedback_post_action(\cm_info $cm, $contextid, $userid, $eventnames, $after = null) { if ($after === null) { if ($this->feedback_check_grades()) { if (!$after = $this->get_graded_date($contextid, $userid)) { return false; } } else { $after = false; } } if (empty($this->activitylogs[$contextid][$userid])) { return false; } foreach ($eventnames as $eventname) { if (!$after) { if (!empty($this->activitylogs[$contextid][$userid][$eventname])) { // If we don't care about when the feedback has been seen we consider this enough. return true; } } else { if (empty($this->activitylogs[$contextid][$userid][$eventname])) { continue; } $timestamps = $this->activitylogs[$contextid][$userid][$eventname]->timecreated; // Faster to start by the end. rsort($timestamps); foreach ($timestamps as $timestamp) { if ($timestamp > $after) { return true; } } } } return false; } /** * Returns the date a user was graded. * * @param int $contextid * @param int $userid * @param bool $checkfeedback Check that the student was graded or check that feedback was given * @return int|false */ protected function get_graded_date($contextid, $userid, $checkfeedback = false) { if (empty($this->grades[$contextid][$userid])) { return false; } foreach ($this->grades[$contextid][$userid] as $gradeitemid => $gradeitem) { // We check that either feedback or the grade is set. if (($checkfeedback && $gradeitem->feedback) || $gradeitem->grade) { // Grab the first graded date. if ($gradeitem->dategraded && (empty($after) || $gradeitem->dategraded < $after)) { $after = $gradeitem->dategraded; } } } if (!isset($after)) { // False if there are no graded items. return false; } return $after; } /** * Returns the activities the user had access to between a time period. * * @param int $sampleid * @param string $tablename * @param int $starttime * @param int $endtime * @return array */ protected function get_student_activities($sampleid, $tablename, $starttime, $endtime) { // May not be available. $user = $this->retrieve('user', $sampleid); if ($this->course === null) { // The indicator scope is a range, so all activities belong to the same course. $this->course = \core_analytics\course::instance($this->retrieve('course', $sampleid)); } if ($this->activitylogs === null) { // Fetch all activity logs in each activity in the course, not restricted to a specific sample so we can cache it. $courseactivities = $this->course->get_all_activities($this->get_activity_type()); // Null if no activities of this type in this course. if (empty($courseactivities)) { $this->activitylogs = false; return null; } $this->activitylogs = $this->fetch_activity_logs($courseactivities, $starttime, $endtime); } if ($this->grades === null) { // Even if this is probably already filled during fill_per_analysable_caches. $this->fetch_student_grades($this->course); } if ($cm = $this->retrieve('cm', $sampleid)) { // Samples are at cm level or below. $useractivities = array(\context_module::instance($cm->id)->id => $cm); } else { // Activities that should be completed during this time period. $useractivities = $this->get_activities($starttime, $endtime, $user); } return $useractivities; } /** * Fetch acitivity logs from database * * @param array $activities * @param int $starttime * @param int $endtime * @return array */ protected function fetch_activity_logs($activities, $starttime = false, $endtime = false) { global $DB; // Filter by context to use the db table index. list($contextsql, $contextparams) = $DB->get_in_or_equal(array_keys($activities), SQL_PARAMS_NAMED); $select = "contextid $contextsql AND timecreated > :starttime AND timecreated <= :endtime"; $params = $contextparams + array('starttime' => $starttime, 'endtime' => $endtime); // Pity that we need to pass through logging readers API when most of the people just uses the standard one. if (!$logstore = \core_analytics\manager::get_analytics_logstore()) { throw new \coding_exception('No log store available'); } $events = $logstore->get_events_select_iterator($select, $params, 'timecreated ASC', 0, 0); // Returs the logs organised by contextid, userid and eventname so it is easier to calculate activities data later. // At the same time we want to keep this array reasonably "not-massive". $processedevents = array(); foreach ($events as $event) { if (!isset($processedevents[$event->contextid])) { $processedevents[$event->contextid] = array(); } if (!isset($processedevents[$event->contextid][$event->userid])) { $processedevents[$event->contextid][$event->userid] = array(); } // Contextid and userid have already been used to index the events, the next field to index by is eventname: // crud is unique per eventname, courseid is the same for all records and we append timecreated. if (!isset($processedevents[$event->contextid][$event->userid][$event->eventname])) { // Remove all data that can change between events of the same type. $data = (object)$event->get_data(); unset($data->id); unset($data->anonymous); unset($data->relateduserid); unset($data->other); unset($data->origin); unset($data->ip); $processedevents[$event->contextid][$event->userid][$event->eventname] = $data; // We want timecreated attribute to be an array containing all user access times. $processedevents[$event->contextid][$event->userid][$event->eventname]->timecreated = array(); } // Add the event timecreated. $processedevents[$event->contextid][$event->userid][$event->eventname]->timecreated[] = intval($event->timecreated); } $events->close(); return $processedevents; } /** * Whether grades should be checked or not when looking for feedback. * * @return bool */ protected function feedback_check_grades() { return true; } /** * Calculates the cognitive depth of a sample. * * @param int $sampleid * @param string $tablename * @param int $starttime * @param int $endtime * @return float|int|null * @throws \coding_exception */ protected function cognitive_calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) { // May not be available. $user = $this->retrieve('user', $sampleid); if (!$useractivities = $this->get_student_activities($sampleid, $tablename, $starttime, $endtime)) { // Null if no activities. return null; } $scoreperactivity = (self::get_max_value() - self::get_min_value()) / count($useractivities); $score = self::get_min_value(); // Iterate through the module activities/resources which due date is part of this time range. foreach ($useractivities as $contextid => $cm) { $potentiallevel = $this->get_cognitive_depth_level($cm); if (!is_int($potentiallevel) || $potentiallevel > self::MAX_COGNITIVE_LEVEL || $potentiallevel < self::COGNITIVE_LEVEL_1) { throw new \coding_exception('Activities\' potential cognitive depth go from 1 to 5.'); } $scoreperlevel = $scoreperactivity / $potentiallevel; switch ($potentiallevel) { case self::COGNITIVE_LEVEL_5: // Cognitive level 5 is to submit after feedback. if ($this->any_feedback('submitted', $cm, $contextid, $user)) { $score += $scoreperlevel * 5; break; } // The user didn't reach the activity max cognitive depth, continue with level 2. case self::COGNITIVE_LEVEL_4: // Cognitive level 4 is to comment on feedback. if ($this->any_feedback('replied', $cm, $contextid, $user)) { $score += $scoreperlevel * 4; break; } // The user didn't reach the activity max cognitive depth, continue with level 2. case self::COGNITIVE_LEVEL_3: // Cognitive level 3 is to view feedback. if ($this->any_feedback('viewed', $cm, $contextid, $user)) { // Max score for level 3. $score += $scoreperlevel * 3; break; } // The user didn't reach the activity max cognitive depth, continue with level 2. case self::COGNITIVE_LEVEL_2: // Cognitive depth level 2 is to submit content. if ($this->any_write_log($contextid, $user)) { $score += $scoreperlevel * 2; break; } // The user didn't reach the activity max cognitive depth, continue with level 1. case self::COGNITIVE_LEVEL_1: // Cognitive depth level 1 is just accessing the activity. if ($this->any_log($contextid, $user)) { $score += $scoreperlevel; } default: } } // To avoid decimal problems. if ($score > self::MAX_VALUE) { return self::MAX_VALUE; } else if ($score < self::MIN_VALUE) { return self::MIN_VALUE; } return $score; } /** * Calculates the social breadth of a sample. * * @param int $sampleid * @param string $tablename * @param int $starttime * @param int $endtime * @return float|int|null */ protected function social_calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) { // May not be available. $user = $this->retrieve('user', $sampleid); if (!$useractivities = $this->get_student_activities($sampleid, $tablename, $starttime, $endtime)) { // Null if no activities. return null; } $scoreperactivity = (self::get_max_value() - self::get_min_value()) / count($useractivities); $score = self::get_min_value(); foreach ($useractivities as $contextid => $cm) { $potentiallevel = $this->get_social_breadth_level($cm); if (!is_int($potentiallevel) || $potentiallevel > self::MAX_SOCIAL_LEVEL || $potentiallevel < self::SOCIAL_LEVEL_1) { throw new \coding_exception('Activities\' potential social breadth go from 1 to ' . community_of_inquiry_activity::MAX_SOCIAL_LEVEL . '.'); } $scoreperlevel = $scoreperactivity / $potentiallevel; switch ($potentiallevel) { case self::SOCIAL_LEVEL_2: case self::SOCIAL_LEVEL_3: case self::SOCIAL_LEVEL_4: case self::SOCIAL_LEVEL_5: // Core activities social breadth only reaches level 2, until core activities social // breadth do not reach level 5 we limit it to what we currently support, which is level 2. // Social breadth level 2 is to view feedback. (Same as cognitive level 3). if ($this->any_feedback('viewed', $cm, $contextid, $user)) { // Max score for level 2. $score += $scoreperlevel * 2; break; } // The user didn't reach the activity max social breadth, continue with level 1. case self::SOCIAL_LEVEL_1: // Social breadth level 1 is just accessing the activity. if ($this->any_log($contextid, $user)) { $score += $scoreperlevel; } } } // To avoid decimal problems. if ($score > self::MAX_VALUE) { return self::MAX_VALUE; } else if ($score < self::MIN_VALUE) { return self::MIN_VALUE; } return $score; } /** * calculate_sample * * @throws \coding_exception * @param int $sampleid * @param string $tablename * @param int $starttime * @param int $endtime * @return float|int|null */ protected function calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) { if ($this->get_indicator_type() == self::INDICATOR_COGNITIVE) { return $this->cognitive_calculate_sample($sampleid, $tablename, $starttime, $endtime); } else if ($this->get_indicator_type() == self::INDICATOR_SOCIAL) { return $this->social_calculate_sample($sampleid, $tablename, $starttime, $endtime); } throw new \coding_exception("Indicator type is invalid."); } /** * Gets the course student grades. * * @param \core_analytics\course $course * @return void */ protected function fetch_student_grades(\core_analytics\course $course) { $courseactivities = $course->get_all_activities($this->get_activity_type()); $this->grades = $course->get_student_grades($courseactivities); } /** * Guesses all activities that were available during a period of time. * * @param int $starttime * @param int $endtime * @param \stdClass|false $student * @return array */ protected function get_activities($starttime, $endtime, $student = false) { $activitytype = $this->get_activity_type(); // Var $student may not be available, default to not calculating dynamic data. $studentid = -1; if ($student) { $studentid = $student->id; } $modinfo = get_fast_modinfo($this->course->get_course_data(), $studentid); $activities = $modinfo->get_instances_of($activitytype); $timerangeactivities = array(); foreach ($activities as $activity) { if (!$this->activity_completed_by($activity, $starttime, $endtime, $student)) { continue; } $timerangeactivities[$activity->context->id] = $activity; } return $timerangeactivities; } /** * Was the activity supposed to be completed during the provided time range?. * * @param \cm_info $activity * @param int $starttime * @param int $endtime * @param \stdClass|false $student * @return bool */ protected function activity_completed_by(\cm_info $activity, $starttime, $endtime, $student = false) { // We can't check uservisible because: // - Any activity with available until would not be counted. // - Sites may block student's course view capabilities once the course is closed. // Students can not view hidden activities by default, this is not reliable 100% but accurate in most of the cases. if ($activity->visible === false) { return false; } // Give priority to the different methods activities have to set a "due" date. $return = $this->activity_type_completed_by($activity, $starttime, $endtime, $student); if (!is_null($return)) { // Method activity_type_completed_by returns null if there is no due date method or there is but it is not set. return $return; } // We skip activities that were not yet visible or their 'until' was not in this $starttime - $endtime range. if ($activity->availability) { $info = new \core_availability\info_module($activity); $activityavailability = $this->availability_completed_by($info, $starttime, $endtime); if ($activityavailability === false) { return false; } else if ($activityavailability === true) { // This activity belongs to this time range. return true; } } // We skip activities in sections that were not yet visible or their 'until' was not in this $starttime - $endtime range. $section = $activity->get_modinfo()->get_section_info($activity->sectionnum); if ($section->availability) { $info = new \core_availability\info_section($section); $sectionavailability = $this->availability_completed_by($info, $starttime, $endtime); if ($sectionavailability === false) { return false; } else if ($sectionavailability === true) { // This activity belongs to this section time range. return true; } } // When the course is using format weeks we use the week's end date. $format = course_get_format($activity->get_modinfo()->get_course()); // We should change this in MDL-60702. if (get_class($format) == 'format_weeks' || is_subclass_of($format, 'format_weeks') && method_exists($format, 'get_section_dates')) { $dates = $format->get_section_dates($section); // We need to consider the +2 hours added by get_section_dates. // Avoid $starttime <= $dates->end because $starttime may be the start of the next week. if ($starttime < ($dates->end - 7200) && $endtime >= ($dates->end - 7200)) { return true; } else { return false; } } if ($activity->sectionnum == 0) { return false; } if (!$this->course->get_end() || !$this->course->get_start()) { debugging('Activities which due date is in a time range can not be calculated ' . 'if the course doesn\'t have start and end date', DEBUG_DEVELOPER); return false; } if (!course_format_uses_sections($this->course->get_course_data()->format)) { // If it does not use sections and there are no availability conditions to access it it is available // and we can not magically classify it into any other time range than this one. return true; } // Split the course duration in the number of sections and consider the end of each section the due // date of all activities contained in that section. $formatoptions = $format->get_format_options(); if (!empty($formatoptions['numsections'])) { $nsections = $formatoptions['numsections']; } else { // There are course format that use sections but without numsections, we fallback to the number // of cached sections in get_section_info_all, not that accurate though. $coursesections = $activity->get_modinfo()->get_section_info_all(); $nsections = count($coursesections); if (isset($coursesections[0])) { // We don't count section 0 if it exists. $nsections--; } } $courseduration = $this->course->get_end() - $this->course->get_start(); $sectionduration = round($courseduration / $nsections); $activitysectionenddate = $this->course->get_start() + ($sectionduration * $activity->sectionnum); if ($activitysectionenddate > $starttime && $activitysectionenddate <= $endtime) { return true; } return false; } /** * True if the activity is due or it has been closed during this period, false if during another period, null if no due time. * * It can be overwritten by activities that allow teachers to set a due date or a time close separately * from Moodle availability system. Note that in most of the cases overwriting get_timeclose_field should * be enough. * * Returns true or false if the time close date falls into the provided time range. Null otherwise. * * @param \cm_info $activity * @param int $starttime * @param int $endtime * @param \stdClass|false $student * @return null */ protected function activity_type_completed_by(\cm_info $activity, $starttime, $endtime, $student = false) { $fieldname = $this->get_timeclose_field(); if (!$fieldname) { // This activity type do not have its own availability control. return null; } $this->fill_instance_data($activity); $instance = $this->instancedata[$activity->instance]; if (!$instance->{$fieldname}) { return null; } if ($starttime < $instance->{$fieldname} && $endtime >= $instance->{$fieldname}) { return true; } return false; } /** * Returns the name of the field that controls activity availability. * * Should be overwritten by activities that allow teachers to set a due date or a time close separately * from Moodle availability system. * * Just 1 field will not be enough for all cases, but for the most simple ones without * overrides and stuff like that. * * @return null|string */ protected function get_timeclose_field() { return null; } /** * Check if the activity/section should have been completed during the provided period according to its availability rules. * * @param \core_availability\info $info * @param int $starttime * @param int $endtime * @return bool|null */ protected function availability_completed_by(\core_availability\info $info, $starttime, $endtime) { $dateconditions = $info->get_availability_tree()->get_all_children('\availability_date\condition'); foreach ($dateconditions as $condition) { // Availability API does not allow us to check from / to dates nicely, we need to be naughty. $conditiondata = $condition->save(); if ($conditiondata->d === \availability_date\condition::DIRECTION_FROM && $conditiondata->t > $endtime) { // Skip this activity if any 'from' date is later than the end time. return false; } else if ($conditiondata->d === \availability_date\condition::DIRECTION_UNTIL && ($conditiondata->t < $starttime || $conditiondata->t > $endtime)) { // Skip activity if any 'until' date is not in $starttime - $endtime range. return false; } else if ($conditiondata->d === \availability_date\condition::DIRECTION_UNTIL && $conditiondata->t < $endtime && $conditiondata->t > $starttime) { return true; } } // This can be interpreted as 'the activity was available but we don't know if its expected completion date // was during this period. return null; } /** * Fills in activity instance data. * * @param \cm_info $cm * @return void */ protected function fill_instance_data(\cm_info $cm) { global $DB; if (!isset($this->instancedata[$cm->instance])) { $this->instancedata[$cm->instance] = $DB->get_record($this->get_activity_type(), array('id' => $cm->instance), '*', MUST_EXIST); } } /** * Defines indicator type. * * @return string */ abstract public function get_indicator_type(); } classes/local/indicator/discrete.php 0000644 00000012073 15215712354 0013572 0 ustar 00 <?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; } } classes/local/indicator/linear.php 0000644 00000010225 15215712354 0013237 0 ustar 00 <?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; } } classes/local/analysis/result_array.php 0000644 00000006525 15215712354 0014360 0 ustar 00 <?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; } } classes/local/analysis/result.php 0000644 00000006224 15215712354 0013156 0 ustar 00 <?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; } classes/local/analysis/result_file.php 0000644 00000021242 15215712354 0014152 0 ustar 00 <?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); } } classes/local/time_splitting/base.php 0000644 00000021734 15215712354 0013765 0 ustar 00 <?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.'); } } } } classes/local/time_splitting/equal_parts.php 0000644 00000006215 15215712354 0015370 0 ustar 00 <?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; } } classes/local/time_splitting/before_now.php 0000644 00000002450 15215712354 0015172 0 ustar 00 <?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 { } classes/local/time_splitting/after_now.php 0000644 00000002445 15215712354 0015035 0 ustar 00 <?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 { } classes/local/time_splitting/after_start.php 0000644 00000007101 15215712354 0015361 0 ustar 00 <?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(); } } classes/local/time_splitting/periodic.php 0000644 00000012357 15215712354 0014652 0 ustar 00 <?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; } } classes/local/time_splitting/accumulative_parts.php 0000644 00000004467 15215712354 0016752 0 ustar 00 <?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; } } classes/local/time_splitting/past_periodic.php 0000644 00000005613 15215712354 0015676 0 ustar 00 <?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; } } classes/local/time_splitting/upcoming_periodic.php 0000644 00000005526 15215712354 0016553 0 ustar 00 <?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(); } } classes/local/target/base.php 0000644 00000041165 15215712354 0012220 0 ustar 00 <?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]); } } } } classes/local/target/binary.php 0000644 00000005675 15215712354 0012600 0 ustar 00 <?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') ); } } classes/local/target/discrete.php 0000644 00000014200 15215712354 0013076 0 ustar 00 <?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; } } classes/local/target/linear.php 0000644 00000007417 15215712354 0012562 0 ustar 00 <?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'); } } classes/regressor.php 0000644 00000004407 15215712354 0010737 0 ustar 00 <?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); } templates/notification_styles.mustache 0000644 00000003667 15215712354 0014407 0 ustar 00 {{! 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> templates/insight_info_message.mustache 0000644 00000002322 15215712354 0014465 0 ustar 00 {{! 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>