ÿØÿà JFIF    ÿÛ „  ( %!1"%)+...383-7(-.+  -%%--------+------------/----------------------------ÿÀ  ¶" ÿÄ     ÿÄ @    !1AQaq‘¡"±ÁÑð2’BRráS‚ñ3b¢CT²Ò #cÿÄ    ÿÄ '   1!AQ2a"‘±#ÿÚ   ? ò(/p‘—–¢·,M|¯G»R£üešT. ôŸEoCFñ‡ 4ÔkåóxR|µÁ¨ÓDà!Ç´µ˜‡p ÍQ‹§!þž^õWsŒy  åÁ§$á«%ºq˜™JyyË0y«^Ϗ -3ÃÏËÈ£Æ\P’ä„s&•‹Üs üô'àWt¹pñCp$bÚF`·7.^)¾!)ÒÙ˜–ÅÏš^h¾ø³·ìe±ŒaeÖ—Í,e=B}Jéߦ$$½—šÙÜCC¯¶è8β–Åkèwx LÉngT±æ¹£Ócœ¿Í/©Éÿ ‰ÚCÒ­mq;Tt¶–}0"zÖPcd1š­¤tˆ„ÐÓâqú[™öUCŠ2޿د­É(¸ßúçšmW,Ò ºf‹Ån™ÄLêÁ8üÁkÁ€L¥ÍuJ1œôrÂSŒÓöt!7EUíïb)AËi¬â-ÙHk8òÁiXYwÅ)O^"ÇØriÛô{»wRUKÙÏÚlÓ¬€‚’שT ] ªb—ÊîKC^×M“xùäñz¬zJ× {4[¹M'IÆeH1LCWÕ]œûº WDæà°OÅ‚¹c²ÞuA–rL`HšpCez‡V’— Ž‚Ë„–ñ­'µR0ÕÖ=À:±G»C\nÆÉ5*¢ †3šhQ4ýÒi$Õ1"Ü]¢p-q3zcQ$ªö¨¥æf›‘»´ÝÚHá„e²JÇ–iÊ:·à¬Øc5Åk»Laª*wi [0Ówk ¦a¦0Õ²ÅÅ‚T¸’³q:Æ<¹ú Ò˜Æ}2VE•Ѐp¡Ô3Úº …h³^LJºçì¥Ánó|˜ÖˆÎˆ+« ¦dø‘Mº–d\ªDðÛ„Ì×LÝ——Ð9Ç|•FèçVm™¨€™3õS–9Äå¢<̉Ìm¢±`·º˜&Z²ž Ë®D«¶»6™‰©­HÈ Àá«h­i³04æ’hxœi3ï¨(ÓL·†‹¶ËpŒ ƒÜÒ? :õdsÀ –3æÓ·Üjâ¬9—]8n¼&]I F£¼…ì–—Œ0v°LǐÅfÛ2TÖ’aµ˜N@ÖBSÏ^à´4-±°b×nÈÈœÆD=KdŒø…ÄÈK,›ÖR§fjü$^ÔÎ!„6^ s:Üun\ôkCÞò÷<—Lúl Í›G¹ÀPá¼Nc¡šÒ…ÙWͳœ±vÊáЄ×9‹P‰cEØ‘™›µŸP<×g1 º©Ø¬wiÁ^l5× I/';Ë+´Z‡ÓÄFÕp[Èð’ HÄ ó 9¬Dl4“é±Ï”V=^Hð;žLädÉa‚°Ë,#/Ž~²BÔÛ QãñQtIeórVB,ÏÂf‹4#Μc‹É3ZIÖ Œ¢¥·üxa‡Ãçè„jŠ[5 Ä!U?,3žÎ×…ðdÓ÷háŠA‰ìÄ4÷ÃÜZÃE{‰®+W\ZÍE[‰»µk»MÝ­`¢©†˜ÃV‹-f¢¡†¢a«eŠ%«Y¨©q%jâd,Ôq`) ¤¦…šˆ ¦E>0Ùb²t–Ÿ  ÷jsςҒKÈcø(é›;k(’#ôŒQ ™C> õ³M9óºÆµ¤çâ;uÀ)5>RÔ¸fÓ~Ȧ—’-$fe«"FâUˆÊwµPÏu w+"åÉó¨6s™Û9(À¨ù©hE°½†n`m~“@©›p!Vkhµ Á1šbEqqÊ@ºèl›2 –Yã%‘‚hÛ{a¶‡ÅÞM²¬çL34®Çl]®‹ŽØŒm'RÜe35æöˆ­Á­ cP9šeÁköo´L³Ì=p8]”‡B¶,šº|ɏdzXˆØk*ÇÚ{#ñ‰pã(€¶‡9ý'šÜ³½¯kƒ†Â’ëSOƒ•Å®H6+a¢µˆ‹Y¨b˜b+XˆÖ!°u!¢"µˆˆn2ˆ Å0Ä`Å Ô®c(ÓÜGº•Ä»Œ âWî%q.áÐÄ÷î%umÍ£+ÜQ,Vn¦-GphU¸¢X­&,Gt ·K¢ÅÅ·¥RÄÊÉbKn N­R2g$VµÕó Qr#–ÓZUÏð0ÎtÙÀœz ëöo“Îk¯n„T°"SÞ+³/…€½"ižãO%Í%&Δâ‘ËØ´IˆÓ°Ë‰ÞM3.™yçÁzit›`4C`k¢¼á!álÄï sÂ[×!¤l¾æo"ƒIÔ'@ªu)3›ô®“@Z à¾] üœ'‡4¸PÞkÁ-qÄH‰ÓÍBd´ÿ —¹°ÄA&ÊUC„öz‚‚RæŠtf¬>˜ÑidšEçÑL÷áý¡V±h§­c¿¥¨ƒ@g¼…Ýö^ÞËc.ž„æ“I[ ª+·zÐÒÚ,½Ð»¶€C›7jk$๖€Ž¾ÅÙpp¶ÍþéΤ ½ØÎŒq ñpæžÅ¥#Øâ49ÃÂK^MYV“,¶Éz_òæ:…;Õ«¦I3âJ£¤û5$7Èxün\â]Êe¯(Ÿ&Ž‹¶2<6Ćf× æ¢ƱqWı[(ÙÂŽç‚T7¨@ÈÉÍß‚ïÙ"&3TYlGށ†)†#5ŠA©^T2ÆÁ†)©†©†¤yGXÁ†©"†)©¼ÅV WS†#©]R–r‹q5ÕbâWþBh¯u+ªÅÄ×ü„nÑ^êkªÁj‰jeœWˆÕÕ`µDµ2Ì+Ä µDµµ5Ô{¤Þ0RF’H÷AÛ<ÊÁ§ ƈæ0Ñ£ê4 öÚµ[¬/1²€â<-¬šö²r×0o×w ´xÐÒË„ g01¬„åEM蛇Ã\6µE›‘fÙO k÷CºnÓ1`iîÒ² áÂ!ÑLÄÿ ³¬\õ*:M=Ç»c‹2[‰ØsÕLò\ŒK L^™üÄN\¶#» Ç\……ßïIœÉ$¸ÎñÍ®ÔHÇ Ûí/´>Li”„†g^$ 7NÍÑÑ2M2ªNg=k½Ð}Ÿl6‡ —JWˆ”ÞéT·+µ¦üÂFè¢Vs6ýÆ4»2+2IÝ9ô9®ÇGÙÇ1+Ä8;*Õ§ž½D*îÑWÚçá3Ma¤·ˆ¥t}š²Ü…Q+Æüµ8HíS”üxGéÁˆ¦Ël„éÈ 5 µÄƒ6äÜ(kIš¯S†fÖ&¸®×v|Äyˆ*禌Éù@7-®Ä[]ÎÖ¿ê`º…=:…žJVnݳvðÖKE¼‚ »mà ÷f®:Ë ™–4rð¡4` ܹçž~‹C=œ…¾Ïj|VDtK.ƒ"9 Ï ­–éXá³6WÒïIL-Ї Ç lib.php000064400000003172152266351020006030 0ustar00. /** * The interface library between the core and the subsystem. * * @package core_analytics * @copyright 2019 David Mudrák * @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/classifier.php000064400000004415152266351020011044 0ustar00. /** * 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/action.php000064400000006733152266351020010202 0ustar00. /** * 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.php000064400000056543152266351020012240 0ustar00. /** * 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/local/time_splitting/periodic.php000064400000012357152266351020014647 0ustar00. /** * 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/after_start.php000064400000007101152266351020015356 0ustar00. /** * 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/accumulative_parts.php000064400000004467152266351020016747 0ustar00. /** * 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/upcoming_periodic.php000064400000005526152266351020016550 0ustar00. /** * 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/time_splitting/equal_parts.php000064400000006215152266351020015365 0ustar00. /** * 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/past_periodic.php000064400000005613152266351020015673 0ustar00. /** * 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/after_now.php000064400000002445152266351020015032 0ustar00. /** * 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/base.php000064400000021734152266351020013762 0ustar00. /** * 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/before_now.php000064400000002450152266351020015167 0ustar00. /** * 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/indicator/discrete.php000064400000012073152266351020013567 0ustar00. /** * 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.php000064400000010225152266351020013234 0ustar00. /** * 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/indicator/community_of_inquiry_activity.php000064400000106420152266351020020171 0ustar00. /** * 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/binary.php000064400000007046152266351020013255 0ustar00. /** * 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/base.php000064400000014414152266351020012700 0ustar00. /** * 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/analysis/result_array.php000064400000006525152266351020014355 0ustar00. /** * 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_file.php000064400000021242152266351020014147 0ustar00. /** * 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/analysis/result.php000064400000006224152266351020013153 0ustar00. /** * 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/target/discrete.php000064400000014200152266351020013073 0ustar00. /** * 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.php000064400000007417152266351020012557 0ustar00. /** * 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/local/target/binary.php000064400000005675152266351020012575 0ustar00. /** * 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/base.php000064400000041165152266351020012215 0ustar00. /** * 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/analyser/by_course.php000064400000005161152266351020013621 0ustar00. /** * 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')); $recordset->close(); 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/analyser/sitewide.php000064400000003346152266351020013447 0ustar00. /** * 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/base.php000064400000042671152266351020012550 0ustar00. /** * 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(); } /** * 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/model_config.php000064400000026004152266351020011343 0ustar00. /** * 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/packable.php000064400000004000152266351020010450 0ustar00. /** * 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/default_bulk_actions.php000064400000010447152266351020013103 0ustar00. /** * 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/prediction_action.php000064400000006666152266351020012427 0ustar00. /** * 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/stats.php000064400000004604152266351020010056 0ustar00. /** * Provides the {@link \core_analytics\stats} class. * * @package core_analytics * @copyright 2019 David Mudrák * @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 * @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/analysable.php000064400000003172152266351020011032 0ustar00. 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/course.php000064400000043434152266351020010224 0ustar00. /** * 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/analysis.php000064400000112103152266351020010535 0ustar00. /** * 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, 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/manager.php000064400000112201152266351020010323 0ustar00. /** * 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_python\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) { try { $processor = $model->get_predictions_processor(); $noprefixnamespace = ltrim(get_class($processor), '\\'); $processorplugin = substr($noprefixnamespace, 0, strpos($noprefixnamespace, '\\')); if ($processorplugin == $plugin) { return true; } } catch (\Exception $e) { // The model does not have a predictions processor. continue; } } // 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; } /** * 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/prediction.php000064400000017133152266351020011061 0ustar00. /** * 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/dataset_manager.php000064400000034634152266351020012045 0ustar00. /** * 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, escape: '\\'); } 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, escape: '\\'); $analysablesvalues[] = fgetcsv($rh, escape: '\\'); // Copy the columns as they are, all files should have the same columns. $columns = fgetcsv($rh, escape: '\\'); } // 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, escape: '\\'); fputcsv($wh, $values, escape: '\\'); fputcsv($wh, $columns, escape: '\\'); // 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, escape: '\\'); // Get rid of the sampleid column name. array_shift($headers); while ($columns = fgetcsv($rh, escape: '\\')) { $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/insights_generator.php000064400000023445152266351020012622 0ustar00. /** * 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/predictor.php000064400000005170152266351020010712 0ustar00. /** * 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/requirements_exception.php000064400000002344152266351020013520 0ustar00. /** * 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/calculable.php000064400000024511152266351020011006 0ustar00. /** * 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/site.php000064400000004742152266351020007667 0ustar00. /** * 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/bulk_action.php000064400000005062152266351020011211 0ustar00. /** * 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/regressor.php000064400000004407152266351020010734 0ustar00. /** * 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); } classes/calculation_info.php000064400000014701152266351020012230 0ustar00. /** * 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/model.php000064400000216346152266351020010030 0ustar00. /** * 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/user.php000064400000011475152266351020007702 0ustar00. /** * 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/admin_setting_predictor.php000064400000004255152266351020013622 0ustar00. /** * 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 ''; } $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')); } } templates/notification_styles.mustache000064400000003667152266351020014404 0ustar00{{! 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 . }} {{! @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.}} templates/insight_info_message.mustache000064400000002322152266351020014462 0ustar00{{! 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 . }} {{! @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 useful because bla bla bla." } }} {{> core_analytics/notification_styles}} {{{insightinfomessage}}}

{{#str}} viewinsight, analytics {{/str}}templates/insight_info_message_prediction.mustache000064400000003465152266351020016713 0ustar00{{! 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 . }} {{! @template core_analytics/insight_info_message_prediction HTML message for insights with one single prediction Classes required for JS: * none Data attributes required for JS: * none Example context (json): { "body": "I am a link in a text body.", "usefulbuttons": { "usefulurl": "https://en.wikipedia.org/wiki/Noodle", "notusefulurl": "https://en.wikipedia.org/wiki/Noodle" } } }} {{> core_analytics/notification_styles}} {{#body}} {{{.}}} {{/body}}
{{#actions}} {{text}}

{{/actions}} {{#usefulbuttons}}
{{! Using target blank for these actions as they only return a small notification.}} {{#str}} washelpful, analytics {{/str}} {{#str}}yes{{/str}} {{#str}}no{{/str}}
{{/usefulbuttons}} upgrade.txt000064400000017230152266351020006741 0ustar00=== 4.5 Onwards === This file has been replaced by UPGRADING.md. See MDL-81125 for further information. === This files describes API changes in analytics sub system, information provided here is intended especially for developers. === 4.2 === * The following analytics target classes, renamed since 3.7, have now been removed completed: - `\core\analytics\target\course_dropout` - `\core\analytics\target\course_competencies` - `\core\analytics\target\course_completion` - `\core\analytics\target\course_gradetopass` - `\core\analytics\target\no_teaching` === 3.11 === * Final deprecation get_enabled_time_splitting_methods. Method has been removed. Use get_time_splitting_methods_for_evaluation instead. * Final deprecation add_builtin_models. Method has been removed. The functionality has been replaced with automatic update of models provided by the core moodle component. There is no need to call this method explicitly any more. Instead, adding new models can be achieved by updating the lib/db/analytics.php file and bumping the core version. * Final deprecation - get_analysables(). Please see get_analysables_interator() instead. get_analysables_iterator() needs to be overridden by the child class. * A new function get_executed_actions() has been added to \core_analytics\prediction class to get all (or filtered by action name) executed actions of a prediction === 3.8 === * "Time-splitting method" have been replaced by "Analysis interval" for the language strings that are displayed in the Moodle UI. The name of several time-splitting methods have been updated according to the new description of the field. * A new target::can_use_timesplitting method must be implemented to discard time-splitting methods that can not be used on a target. * Targets can now implement get_insight_body and get_insight_body_for_prediction to set the body of the insight. * Indicators can add information about calculated values by calling add_shared_calculation_info(). This data is later available for targets in get_insight_body_for_prediction(), it can be accessed appending ':extradata' to the indicator name (e.g. $sampledata['\mod_yeah\analytics\indicator\ou:extradata') * A new \core_analytics\local\time_splitting\past_periodic abstract class has been added. Time-splitting methods extending \core_analytics\local\time_splitting\periodic directly should be extending past_periodic now. 'periodic' can still be directly extended by implementing get_next_range and get_first_start methods. * Targets can now specify a list of bulk actions in bulk_actions(). core_analytics\prediction_action is now extending core_analytics\action and a new core_analytics\bulk_action class has been added. Actions can now specify a type in its constructor: core_analytics\action::TYPE_POSITIVE, TYPE_NEUTRAL or TYPE_NEGATIVE. A list of default bulk actions is available in \core_analytics\default_bulk_actions. * The default suggested actions provided to users changed: * For targets with one single sample per analysable (e.g. upcoming activities due) the default actions are Useful and Not useful. * For targets with multiple samples per analysable (e.g. students at risk) the default actions are Accept, Not applicable and Incorrectly flagged. * The suggested actions for the existing models have been reworded: * Predictions flagged as "Acknowledged" in models whose targets use analysers that provide one sample per analysable (e.g. upcoming activities due) have been updated to "Useful" flag. * Predictions flagged as "Not useful" in models whose targets use analysers that provide multiple samples per analysable (e.g. students at risk or no teaching) have been updated to "Incorrectly flagged". * \core_analytics\predictor::delete_output_dir has a new 2nd parameter, $uniquemodelid. * Analyser's get_analysables_iterator and get_iterator_sql have a new $contexts parameter to limit the returned analysables to the ones that depend on the provided contexts. * Analysers can implement a context_restriction_support() method to restrict models to a subset of the contents in the site. Only CONTEXT_COURSE and CONTEXT_COURSECAT are supported. === 3.7 === * \core_analytics\regressor::evaluate_regression and \core_analytics\classifier::evaluate_classification have been updated to include a new $trainedmodeldir param. This new param will be used to evaluate the existing trained model. * Plugins and core subsystems can now declare default prediction models by describing them in their db/analytics.php file. Models should not be created manually via the db/install.php file any more. * The method \core_analytics\manager::add_builtin_models() has been deprecated. The functionality has been replaced with automatic update of models provided by the core moodle component. There is no need to call this method explicitly any more. Instead, adding new models can be achieved by updating the lib/db/analytics.php file and bumping the core version. * \core_analytics\model::execute_prediction_callbacks now returns an array with both sample's contexts and the prediction records. * \core_analytics\model::export() now expects the renderer instance as an argument. * Time splitting methods: * \core_analytics\local\time_splitting\base::append_rangeindex and \core_analytics\local\time_splitting\base::infer_sample_info are now marked as final and can not be overwritten. * Can now overwrite include_range_info_in_training_data() and get_training_ranges() methods. They can be used to create time splitting methods with a pre-defined number of ranges. * Can now overwrite cache_indicator_calculations(). You should return false if the time frames generated by your time-splitting method are unique and / or can hardly be reused by further models. * Can now overwrite valid_for_evaluation(). You can return false if the time-splitting method can not be used to evaluate prediction models or if it does not make sense to evaluate prediction models with it, as for example upcoming_periodic children classes. * \core_analytics\local\analyser\base::get_most_recent_prediction_range has been moved to \core_analytics\local\time_splitting\base::get_most_recent_prediction_range and it is not overwritable by time splitting methods. * Targets: * The visibility of the following methods must now be public: ignored_predicted_classes() and get_insights_users() * Prediction_actions() has now a 3rd parameter $isinsightuser. This parameter is true when we are listing actions for the user that will receives the insight. * Can now implement a always_update_analysis_time() method so analysable elements' timeanalysed is only updated when analysable elements have been successfully evaluated. It is useful for lightweight targets. * Can not implement two new methods to tune the insights generated by the model: get_insight_subject() and get_insight_context_url(). * Analysers: * The visibility of get_all_samples() method must now be public. * get_analysables() method has been deprecated in favour of a new get_analysables_interator() for performance reasons. * Can overwrite a new one_sample_per_analysable() method if the analysables they use only have one sample. The insights generated by models will then include the suggested actions in the notification. === 3.5 === * There are two new methods for analysers, processes_user_data() and join_sample_user(). You need to overwrite them if your analyser uses user data. As a general statement, you should overwrite these new methods if your samples return 'user' data. These new methods are used for analytics' privacy API implementation. UPGRADING.md000064400000000304152266351020006405 0ustar00# core_analytics (subsystem) Upgrade notes ## 5.0 ### Removed - Remove chat and survey from core_analytics. For more information see [MDL-82457](https://tracker.moodle.org/browse/MDL-82457) tests/fixtures/test_static_target_shortname.php000064400000004741152266351020016254 0ustar00. /** * Test static target. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/test_target_shortname.php'); /** * Test static target. * * Testing target extension to make it static and to use a different analyser * just to try a different one. Method calculate_sample is exactly the same. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_static_target_shortname extends test_target_shortname { /** * Returns a lang_string object representing the name for the indicator. * * Used as column identificator. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { // Using a string that exists and contains a corresponding '_help' string. return new \lang_string('allowstealthmodules'); } /** * based_on_assumptions * * @return bool */ public static function based_on_assumptions() { return true; } /** * Everything yep, this is just for testing. * * @param \core_analytics\local\time_splitting\base $timesplitting * @return bool */ public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool { return true; } /** * Different analyser just to test a different one. * * @return string */ public function get_analyser_class() { return '\core\analytics\analyser\courses'; } } tests/fixtures/test_target_shortname.php000064400000010650152266351020014701 0ustar00. /** * Test target. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Test target. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_target_shortname extends \core_analytics\local\target\binary { /** * Returns a lang_string object representing the name for the indicator. * * Used as column identificator. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { // Using a string that exists and contains a corresponding '_help' string. return new \lang_string('allowstealthmodules'); } /** * predictions * * @var array */ protected $predictions = array(); /** * get_analyser_class * * @return string */ public function get_analyser_class() { return '\core\analytics\analyser\site_courses'; } /** * classes_description * * @return string[] */ public static function classes_description() { return array( 'Course fullname first char is A', 'Course fullname first char is not A' ); } /** * We don't want to discard results. * @return float */ protected function min_prediction_score() { return null; } /** * We don't want to discard results. * @return array */ public function ignored_predicted_classes() { return array(); } /** * Only past stuff. * * @param \core_analytics\local\time_splitting\base $timesplitting * @return bool */ public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool { return ($timesplitting instanceof \core_analytics\local\time_splitting\before_now); } /** * is_valid_analysable * * @param \core_analytics\analysable $analysable * @param bool $fortraining * @return bool */ public function is_valid_analysable(\core_analytics\analysable $analysable, $fortraining = true) { // This is testing, let's make things easy. return true; } /** * is_valid_sample * * @param int $sampleid * @param \core_analytics\analysable $analysable * @param bool $fortraining * @return bool */ public function is_valid_sample($sampleid, \core_analytics\analysable $analysable, $fortraining = true) { // We skip not-visible courses during training as a way to emulate the training data / prediction data difference. // In normal circumstances is_valid_sample will return false when they receive a sample that can not be // processed. if (!$fortraining) { return true; } $sample = $this->retrieve('course', $sampleid); if ($sample->visible == 0) { return false; } return true; } /** * calculate_sample * * @param int $sampleid * @param \core_analytics\analysable $analysable * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) { $sample = $this->retrieve('course', $sampleid); $firstchar = substr($sample->shortname, 0, 1); if ($firstchar === 'a') { return 1; } else { return 0; } } } tests/fixtures/test_analysis.php000064400000002747152266351020013166 0ustar00. /** * Test analyser. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Test analyser. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_analysis extends \core_analytics\analysis { /** * Overwritten to add a delay. * * @param \core_analytics\analysable $analysable * @return array */ public function process_analysable(\core_analytics\analysable $analysable): array { // Half a second. sleep(1); return parent::process_analysable($analysable); } } tests/fixtures/test_indicator_null.php000064400000003714152266351020014344 0ustar00. /** * Test indicator. Always null. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Test indicator. Always null. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_indicator_null extends \core_analytics\local\indicator\binary { /** * Returns a lang_string object representing the name for the indicator. * * Used as column identificator. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { // Using a string that exists and contains a corresponding '_help' string. return new \lang_string('allowstealthmodules'); } /** * calculate_sample * * @param int $sampleid * @param string $samplesorigin * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, $samplesorigin, $starttime, $endtime) { return null; } } tests/fixtures/test_indicator_random.php000064400000003676152266351020014661 0ustar00. /** * Test indicator. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Test indicator. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_indicator_random extends \core_analytics\local\indicator\linear { /** * Returns a lang_string object representing the name for the indicator. * * Used as column identificator. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { // Using a string that exists and contains a corresponding '_help' string. return new \lang_string('allowstealthmodules'); } /** * calculate_sample * * @param int $sampleid * @param string $samplesorigin * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, $samplesorigin, $starttime, $endtime) { return mt_rand(-1, 1); } } tests/fixtures/test_target_shortname_multiclass.php000064400000013551152266351020017144 0ustar00. /** * Multi-class classifier target. * * @package core_analytics * @copyright 2019 Apetrei Vlad * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Multi-class classifier target. * * @package core_analytics * @copyright 2019 Apetrei Vlad * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_target_shortname_multiclass extends \core_analytics\local\target\discrete { /** * Returns a lang_string object representing the name for the indicator. * * Used as column identificator. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { // Using a string that exists and contains a corresponding '_help' string. return new \lang_string('allowstealthmodules'); } /** * predictions * * @var array */ protected $predictions = array(); /** * is_linear * * @return bool */ public function is_linear() { return false; } /** * Returns the target discrete values. * * Only useful for targets using discrete values, must be overwriten if it is the case. * * @return array */ final public static function get_classes() { return array(0, 1, 2); } /** * Is the calculated value a positive outcome of this target? * * @param string $value * @param string $ignoredsubtype * @return int */ public function get_calculation_outcome($value, $ignoredsubtype = false) { if (!self::is_a_class($value)) { throw new \moodle_exception('errorpredictionformat', 'analytics'); } if (in_array($value, $this->ignored_predicted_classes(), false)) { // Just in case, if it is ignored the prediction should not even be recorded but if it would, it is ignored now, // which should mean that is it nothing serious. return self::OUTCOME_VERY_POSITIVE; } // By default binaries are danger when prediction = 1. if ($value) { return self::OUTCOME_VERY_NEGATIVE; } return self::OUTCOME_VERY_POSITIVE; } /** * get_analyser_class * * @return string */ public function get_analyser_class() { return '\core\analytics\analyser\site_courses'; } /** * We don't want to discard results. * @return float */ protected function min_prediction_score() { return null; } /** * We don't want to discard results. * @return array */ public function ignored_predicted_classes() { return array(); } /** * is_valid_analysable * * @param \core_analytics\analysable $analysable * @param bool $fortraining * @return bool */ public function is_valid_analysable(\core_analytics\analysable $analysable, $fortraining = true) { // This is testing, let's make things easy. return true; } /** * is_valid_sample * * @param int $sampleid * @param \core_analytics\analysable $analysable * @param bool $fortraining * @return bool */ public function is_valid_sample($sampleid, \core_analytics\analysable $analysable, $fortraining = true) { // We skip not-visible courses during training as a way to emulate the training data / prediction data difference. // In normal circumstances is_valid_sample will return false when they receive a sample that can not be // processed. if (!$fortraining) { return true; } $sample = $this->retrieve('course', $sampleid); if ($sample->visible == 0) { return false; } return true; } /** * classes_description * * @return string[] */ protected static function classes_description() { return array( get_string('first class'), get_string('second class'), get_string('third class') ); } /** * calculate_sample * * @param int $sampleid * @param \core_analytics\analysable $analysable * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) { $sample = $this->retrieve('course', $sampleid); $firstchar = substr($sample->shortname, 0, 1); switch ($firstchar) { case 'a': return 0; case 'b': return 1; case 'c': return 2; } } /** * Can the provided time-splitting method be used on this target?. * * Time-splitting methods not matching the target requirements will not be selectable by models based on this target. * * @param \core_analytics\local\time_splitting\base $timesplitting * @return bool */ public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool { return true; } } tests/fixtures/test_indicator_multiclass.php000064400000005014152266351020015545 0ustar00. /** * Multiclass test indicator. * * @package core_analytics * @copyright 2019 Vlad Apetrei * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Multiclass test indicator. * * @package core_analytics * @copyright 2019 Vlad Apetrei * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_indicator_multiclass extends \core_analytics\local\indicator\linear { /** * Returns a lang_string object representing the name for the indicator. * * Used as column identificator. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { // Using a string that exists and contains a corresponding '_help' string. return new \lang_string('allowstealthmodules'); } /** * include_averages * * @return bool */ protected static function include_averages() { return false; } /** * required_sample_data * * @return string[] */ public static function required_sample_data() { return array('course'); } /** * calculate_sample * * @param int $sampleid * @param string $samplesorigin * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, $samplesorigin, $starttime, $endtime) { $course = $this->retrieve('course', $sampleid); $firstchar = substr($course->fullname, 0, 1); if ($firstchar === 'a') { return 1; } else if ($firstchar === 'b') { return -1; } else if ($firstchar === 'c') { return 1; } else { return self::MAX_VALUE; } } } tests/fixtures/test_indicator_min.php000064400000003674152266351020014162 0ustar00. /** * Test indicator. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Test indicator. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_indicator_min extends \core_analytics\local\indicator\binary { /** * Returns a lang_string object representing the name for the indicator. * * Used as column identificator. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { // Using a string that exists and contains a corresponding '_help' string. return new \lang_string('allowstealthmodules'); } /** * calculate_sample * * @param int $sampleid * @param string $samplesorigin * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, $samplesorigin, $starttime, $endtime) { return self::MIN_VALUE; } } tests/fixtures/test_indicator_max.php000064400000003674152266351020014164 0ustar00. /** * Test indicator. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Test indicator. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_indicator_max extends \core_analytics\local\indicator\binary { /** * Returns a lang_string object representing the name for the indicator. * * Used as column identificator. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { // Using a string that exists and contains a corresponding '_help' string. return new \lang_string('allowstealthmodules'); } /** * calculate_sample * * @param int $sampleid * @param string $samplesorigin * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, $samplesorigin, $starttime, $endtime) { return self::MAX_VALUE; } } tests/fixtures/test_timesplitting_upcoming_seconds.php000064400000003073152266351020017647 0ustar00. /** * Test time splitting. * * @package core_analytics * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Test time splitting. * * @package core_analytics * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_timesplitting_upcoming_seconds extends \core_analytics\local\time_splitting\upcoming_periodic { /** * Every second. * @return \DateInterval */ public function periodicity() { return new \DateInterval('PT1S'); } /** * Just to comply with the interface. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('error'); } } tests/fixtures/test_target_course_users.php000064400000003533152266351020015424 0ustar00. /** * Test target. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/test_target_site_users.php'); /** * Test target. * * @package core_analytics * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_target_course_users extends test_target_site_users { /** * get_analyser_class * * @return string */ public function get_analyser_class() { return '\core\analytics\analyser\student_enrolments'; } /** * Returns a lang_string object representing the name for the indicator. * * Used as column identificator. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { // Using a string that exists and contains a corresponding '_help' string. return new \lang_string('adminhelpedituser'); } } tests/fixtures/test_indicator_discrete.php000064400000005120152266351020015165 0ustar00. /** * Test indicator. * * @package core_analytics * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Test indicator. * * @package core_analytics * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_indicator_discrete extends \core_analytics\local\indicator\discrete { /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { // Using a string that exists and contains a corresponding '_help' string. return new \lang_string('allowstealthmodules'); } /** * The different classes this discrete indicator provides. * @return [type] [description] */ protected static function get_classes() { return [0, 1, 2, 3, 4]; } /** * Just for testing. * * @param float $value * @param string $subtype * @return string */ public function get_calculation_outcome($value, $subtype = false) { return self::OUTCOME_OK; } /** * Custom indicator calculated value display as otherwise we would display meaningless numbers to users. * * @param float $value * @param string $subtype * @return string */ public function get_display_value($value, $subtype = false) { return $value; } /** * calculate_sample * * @param int $sampleid * @param string $sampleorigin * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, $sampleorigin, $starttime = false, $endtime = false) { return 4; } } tests/fixtures/test_timesplitting_seconds.php000064400000003056152266351020015747 0ustar00. /** * Test time splitting. * * @package core_analytics * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Test time splitting. * * @package core_analytics * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_timesplitting_seconds extends \core_analytics\local\time_splitting\past_periodic { /** * Every second. * @return \DateInterval */ public function periodicity() { return new \DateInterval('PT1S'); } /** * Just to comply with the interface. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('error'); } } tests/fixtures/test_target_site_users.php000064400000010625152266351020015070 0ustar00. /** * Test target. * * @package core_analytics * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/test_site_users_analyser.php'); /** * Test target. * * @package core_analytics * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_target_site_users extends \core_analytics\local\target\binary { /** * Returns a lang_string object representing the name for the indicator. * * Used as column identificator. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { // Using a string that exists and contains a corresponding '_help' string. return new \lang_string('adminhelplogs'); } /** * predictions * * @var array */ protected $predictions = array(); /** * get_analyser_class * * @return string */ public function get_analyser_class() { return 'test_site_users_analyser'; } /** * Everything yep, this is just for testing. * * @param \core_analytics\local\time_splitting\base $timesplitting * @return bool */ public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool { return true; } /** * classes_description * * @return string[] */ public static function classes_description() { return array( 'firstname first char is A', 'firstname first char is not A' ); } /** * We don't want to discard results. * @return float */ protected function min_prediction_score() { return null; } /** * We don't want to discard results. * @return array */ public function ignored_predicted_classes() { return array(); } /** * is_valid_analysable * * @param \core_analytics\analysable $analysable * @param bool $fortraining * @return bool */ public function is_valid_analysable(\core_analytics\analysable $analysable, $fortraining = true) { // This is testing, let's make things easy. return true; } /** * is_valid_sample * * @param int $sampleid * @param \core_analytics\analysable $analysable * @param bool $fortraining * @return bool */ public function is_valid_sample($sampleid, \core_analytics\analysable $analysable, $fortraining = true) { // We skip not-visible courses during training as a way to emulate the training data / prediction data difference. // In normal circumstances is_valid_sample will return false when they receive a sample that can not be // processed. if (!$fortraining) { return true; } $sample = $this->retrieve('user', $sampleid); if ($sample->lastname == 'b') { return false; } return true; } /** * calculate_sample * * @param int $sampleid * @param \core_analytics\analysable $analysable * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) { $sample = $this->retrieve('user', $sampleid); $firstchar = substr($sample->firstname, 0, 1); if ($firstchar === 'a') { return 1; } else { return 0; } } } tests/fixtures/test_site_users_analyser.php000064400000007703152266351020015423 0ustar00. /** * Test analyser * * @package core * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Test analyser * * @package core * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_site_users_analyser extends \core_analytics\local\analyser\sitewide { /** * Samples origin is course table. * * @return string */ public function get_samples_origin() { return 'user'; } /** * Returns the sample analysable * * @param int $sampleid * @return \core_analytics\analysable */ public function get_sample_analysable($sampleid) { return new \core_analytics\site(); } /** * Data this analyer samples provide. * * @return string[] */ protected function provided_sample_data() { return array('user'); } /** * Returns the sample context. * * @param int $sampleid * @return \context */ public function sample_access_context($sampleid) { return \context_system::instance(); } /** * Returns all site courses. * * @param \core_analytics\analysable $site * @return array */ public function get_all_samples(\core_analytics\analysable $site) { global $DB; $users = $DB->get_records('user'); $userids = array_keys($users); $sampleids = array_combine($userids, $userids); $users = array_map(function($user) { return array('user' => $user); }, $users); return array($sampleids, $users); } /** * Return all complete samples data from sample ids. * * @param int[] $sampleids * @return array */ public function get_samples($sampleids) { global $DB; list($userssql, $params) = $DB->get_in_or_equal($sampleids, SQL_PARAMS_NAMED); $users = $DB->get_records_select('user', "id {$userssql}", $params); $userids = array_keys($users); $sampleids = array_combine($userids, $userids); $users = array_map(function($user) { return array('user' => $user); }, $users); return array($sampleids, $users); } /** * Returns the description of a sample. * * @param int $sampleid * @param int $contextid * @param array $sampledata * @return array array(string, \renderable) */ public function sample_description($sampleid, $contextid, $sampledata) { $description = fullname($sampledata['user']); $userimage = new \pix_icon('i/user', get_string('user')); return array($description, $userimage); } /** * We need to delete associated data if a user requests his data to be deleted. * * @return bool */ public function processes_user_data() { return true; } /** * Join the samples origin table with the user id table. * * @param string $sampletablealias * @return string */ public function join_sample_user($sampletablealias) { return "JOIN {user} u ON u.id = {$sampletablealias}.sampleid"; } } tests/fixtures/test_indicator_fullname.php000064400000005122152266351020015170 0ustar00. /** * Test indicator. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Test indicator. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_indicator_fullname extends \core_analytics\local\indicator\linear { /** * Returns a lang_string object representing the name for the indicator. * * Used as column identificator. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { // Using a string that exists and contains a corresponding '_help' string. return new \lang_string('allowstealthmodules'); } /** * include_averages * * @return bool */ protected static function include_averages() { return false; } /** * required_sample_data * * @return string[] */ public static function required_sample_data() { return array('course'); } /** * calculate_sample * * @param int $sampleid * @param string $samplesorigin * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, $samplesorigin, $starttime, $endtime) { $course = $this->retrieve('course', $sampleid); $firstchar = substr($course->fullname, 0, 1); if ($firstchar === 'a') { return self::MIN_VALUE; } else if ($firstchar === 'b') { return -0.2; } else if ($firstchar === 'c') { return 0.2; } else { return self::MAX_VALUE; } } } tests/fixtures/db_analytics_php/invalid_target.php000064400000002351152266351020016572 0ustar00. /** * Example of an invalid db/analytics.php file content used for unit tests. * * @package core_analytics * @category test * @copyright 2019 David Mudrák * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $models = [ [ 'target' => 'there_should_be_a_valid_fully_qualified_classname_here', 'indicators' => [ '\core_course\analytics\indicator\no_teacher', '\core_course\analytics\indicator\no_student', ], ], ]; tests/fixtures/db_analytics_php/no_teaching.php000064400000002476152266351020016064 0ustar00. /** * Example of a valid db/analytics.php file content used for unit tests. * * @package core_analytics * @category test * @copyright 2019 David Mudrák * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $models = [ [ 'target' => '\core_course\analytics\target\no_teaching', 'indicators' => [ '\core_course\analytics\indicator\no_teacher', '\core_course\analytics\indicator\no_student', ], 'timesplitting' => '\core\analytics\time_splitting\single_range', 'enabled' => true, ], ]; tests/fixtures/db_analytics_php/invalid_indicators.php000064400000002302152266351020017437 0ustar00. /** * Example of an invalid db/analytics.php file content used for unit tests. * * @package core_analytics * @category test * @copyright 2019 David Mudrák * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $models = [ [ 'target' => 'test_target_course_level_shortname', 'indicators' => [ '\core_course\analytics\indicator\no_teacher', '\non\existing\class\name', ], ], ]; tests/fixtures/db_analytics_php/invalid_time_splitting_fq.php000064400000002352152266351020021026 0ustar00. /** * Example of an invalid db/analytics.php file content used for unit tests. * * @package core_analytics * @category test * @copyright 2019 David Mudrák * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $models = [ [ 'target' => 'test_target_course_level_shortname', 'indicators' => [ '\core_course\analytics\indicator\no_teacher', ], 'timesplitting' => 'local_customplugin_non_fully_qualified_class_name', ], ]; tests/fixtures/db_analytics_php/missing_indicators.php000064400000002072152266351020017466 0ustar00. /** * Example of an invalid db/analytics.php file content used for unit tests. * * @package core_analytics * @category test * @copyright 2019 David Mudrák * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $models = [ [ 'target' => 'test_target_course_level_shortname', ], ]; tests/fixtures/db_analytics_php/missing_target.php000064400000002233152266351020016614 0ustar00. /** * Example of an invalid db/analytics.php file content used for unit tests. * * @package core_analytics * @category test * @copyright 2019 David Mudrák * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $models = [ [ 'indicators' => [ '\core_course\analytics\indicator\no_teacher', '\core_course\analytics\indicator\no_student', ], ], ]; tests/fixtures/db_analytics_php/invalid_enabled.php000064400000002361152266351020016677 0ustar00. /** * Example of an invalid db/analytics.php file content used for unit tests. * * @package core_analytics * @category test * @copyright 2019 David Mudrák * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $models = [ [ 'target' => 'test_target_course_level_shortname', 'indicators' => [ '\core_course\analytics\indicator\no_teacher', ], // Cannot be enabled without timesplitting defined. 'enabled' => true, ], ]; tests/fixtures/db_analytics_php/invalid_time_splitting.php000064400000002321152266351020020334 0ustar00. /** * Example of an invalid db/analytics.php file content used for unit tests. * * @package core_analytics * @category test * @copyright 2019 David Mudrák * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $models = [ [ 'target' => 'test_target_course_level_shortname', 'indicators' => [ '\core_course\analytics\indicator\no_teacher', ], 'timesplitting' => '\non\existing\class\name', ], ]; tests/fixtures/test_target_course_level_shortname.php000064400000002621152266351020017447 0ustar00. /** * Test target. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/test_target_shortname.php'); /** * Test target. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class test_target_course_level_shortname extends test_target_shortname { /** * get_analyser_class * * @return string */ public function get_analyser_class() { return '\core\analytics\analyser\courses'; } } tests/privacy/provider_test.php000064400000060326152266351020012776 0ustar00. namespace core_analytics\privacy; use core_privacy\local\request\approved_userlist; use core_analytics\tests\mlbackend_helper_trait; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/../fixtures/test_indicator_max.php'); require_once(__DIR__ . '/../fixtures/test_indicator_min.php'); require_once(__DIR__ . '/../fixtures/test_target_site_users.php'); require_once(__DIR__ . '/../fixtures/test_target_course_users.php'); /** * Unit tests for privacy. * * @package core_analytics * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class provider_test extends \core_privacy\tests\provider_testcase { use mlbackend_helper_trait; /** @var \core_analytics\model Store Model 1. */ protected $model1; /** @var \core_analytics\model Store Model 2. */ protected $model2; /** @var \stdClass $modelobj1 Store Model 1 object. */ protected $modelobj1; /** @var \stdClass $modelobj2 Store Model 2 object. */ protected $modelobj2; /** @var \stdClass $u1 User 1 record. */ protected $u1; /** @var \stdClass $u2 User 2 record. */ protected $u2; /** @var \stdClass $u3 User 3 record. */ protected $u3; /** @var \stdClass $u4 User 4 record. */ protected $u4; /** @var \stdClass $u5 User 5 record. */ protected $u5; /** @var \stdClass $u6 User 6 record. */ protected $u6; /** @var \stdClass $u7 User 7 record. */ protected $u7; /** @var \stdClass $u8 User 8 record. */ protected $u8; /** @var \stdClass $u9 User 9 record. */ protected $u9; /** @var \stdClass $u10 User 10 record. */ protected $u10; /** @var \stdClass $c1 Course 1 record. */ protected $c1; /** @var \stdClass $c2 Course 2 record. */ protected $c2; public function setUp(): void { if (!self::is_mlbackend_python_configured()) { $this->markTestSkipped('mlbackend_python is not configured.'); } parent::setUp(); $this->resetAfterTest(true); $this->setAdminUser(); $timesplittingid = '\core\analytics\time_splitting\single_range'; $target = \core_analytics\manager::get_target('test_target_site_users'); $indicators = array('test_indicator_max'); foreach ($indicators as $key => $indicator) { $indicators[$key] = \core_analytics\manager::get_indicator($indicator); } $this->model1 = \core_analytics\model::create($target, $indicators, $timesplittingid); $this->modelobj1 = $this->model1->get_model_obj(); $target = \core_analytics\manager::get_target('test_target_course_users'); $indicators = array('test_indicator_min'); foreach ($indicators as $key => $indicator) { $indicators[$key] = \core_analytics\manager::get_indicator($indicator); } $this->model2 = \core_analytics\model::create($target, $indicators, $timesplittingid); $this->modelobj2 = $this->model1->get_model_obj(); $this->u1 = $this->getDataGenerator()->create_user(['firstname' => 'a111111111111', 'lastname' => 'a']); $this->u2 = $this->getDataGenerator()->create_user(['firstname' => 'a222222222222', 'lastname' => 'a']); $this->u3 = $this->getDataGenerator()->create_user(['firstname' => 'b333333333333', 'lastname' => 'b']); $this->u4 = $this->getDataGenerator()->create_user(['firstname' => 'b444444444444', 'lastname' => 'b']); $this->u5 = $this->getdatagenerator()->create_user(['firstname' => 'a555555555555', 'lastname' => 'a']); $this->u6 = $this->getdatagenerator()->create_user(['firstname' => 'a666666666666', 'lastname' => 'a']); $this->u7 = $this->getdatagenerator()->create_user(['firstname' => 'b777777777777', 'lastname' => 'b']); $this->u8 = $this->getDataGenerator()->create_user(['firstname' => 'b888888888888', 'lastname' => 'b']); $this->u9 = $this->getDataGenerator()->create_user(['firstname' => 'a999999999999', 'lastname' => 'a']); $this->u10 = $this->getDataGenerator()->create_user(['firstname' => 'b000000000000', 'lastname' => 'a']); $this->c1 = $this->getDataGenerator()->create_course(['visible' => false]); $this->c2 = $this->getDataGenerator()->create_course(); // Enrol users to course 1. $this->getDataGenerator()->enrol_user($this->u1->id, $this->c1->id, 'student'); $this->getDataGenerator()->enrol_user($this->u2->id, $this->c1->id, 'student'); $this->getDataGenerator()->enrol_user($this->u3->id, $this->c1->id, 'student'); $this->getDataGenerator()->enrol_user($this->u4->id, $this->c1->id, 'student'); $this->getDataGenerator()->enrol_user($this->u5->id, $this->c1->id, 'student'); $this->getDataGenerator()->enrol_user($this->u6->id, $this->c1->id, 'student'); $this->getDataGenerator()->enrol_user($this->u7->id, $this->c1->id, 'student'); $this->getDataGenerator()->enrol_user($this->u8->id, $this->c1->id, 'student'); $this->getDataGenerator()->enrol_user($this->u9->id, $this->c1->id, 'student'); $this->getDataGenerator()->enrol_user($this->u10->id, $this->c1->id, 'student'); // Enrol users to course 2. $this->getDataGenerator()->enrol_user($this->u1->id, $this->c2->id, 'student'); $this->getDataGenerator()->enrol_user($this->u2->id, $this->c2->id, 'student'); $this->getDataGenerator()->enrol_user($this->u3->id, $this->c2->id, 'student'); $this->getDataGenerator()->enrol_user($this->u4->id, $this->c2->id, 'student'); $this->getDataGenerator()->enrol_user($this->u5->id, $this->c2->id, 'student'); $this->getDataGenerator()->enrol_user($this->u6->id, $this->c2->id, 'student'); $this->getDataGenerator()->enrol_user($this->u7->id, $this->c2->id, 'student'); $this->getDataGenerator()->enrol_user($this->u8->id, $this->c2->id, 'student'); $this->getDataGenerator()->enrol_user($this->u9->id, $this->c2->id, 'student'); $this->getDataGenerator()->enrol_user($this->u10->id, $this->c2->id, 'student'); $this->setAdminUser(); $this->model1->enable(); $this->model1->train(); $this->model1->predict(); $this->model2->enable(); $this->model2->train(); $this->model2->predict(); list($total, $predictions) = $this->model2->get_predictions(\context_course::instance($this->c1->id)); $this->setUser($this->u3); $prediction = reset($predictions); $prediction->action_executed(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $this->model2->get_target()); $this->setAdminUser(); } /** * Test fetching users within a context. */ public function test_get_users_in_context(): void { global $CFG; $component = 'core_analytics'; $course1context = \context_course::instance($this->c1->id); $course2context = \context_course::instance($this->c2->id); $systemcontext = \context_system::instance(); $expected = [$this->u1->id, $this->u2->id, $this->u3->id, $this->u4->id, $this->u5->id, $this->u6->id, $this->u7->id, $this->u8->id, $this->u9->id, $this->u10->id]; // Check users exist in the relevant contexts. $userlist = new \core_privacy\local\request\userlist($course1context, $component); provider::get_users_in_context($userlist); $actual = $userlist->get_userids(); sort($actual); $this->assertEquals($expected, $actual); $userlist = new \core_privacy\local\request\userlist($course2context, $component); provider::get_users_in_context($userlist); $actual = $userlist->get_userids(); sort($actual); $this->assertEquals($expected, $actual); // System context will also find guest and admin user, add to expected before testing. $expected = array_merge($expected, [$CFG->siteguest, get_admin()->id]); sort($expected); $userlist = new \core_privacy\local\request\userlist($systemcontext, $component); provider::get_users_in_context($userlist); $actual = $userlist->get_userids(); sort($actual); $this->assertEquals($expected, $actual); } /** * Test delete a context. * * @return null */ public function test_delete_context_data(): void { global $DB; // We have 4 predictions for model1 and 8 predictions for model2. $this->assertEquals(12, $DB->count_records('analytics_predictions')); $this->assertEquals(32, $DB->count_records('analytics_indicator_calc')); // We have 1 prediction action. $this->assertEquals(1, $DB->count_records('analytics_prediction_actions')); $coursecontext = \context_course::instance($this->c1->id); // Delete the course that was used for prediction. provider::delete_data_for_all_users_in_context($coursecontext); // The course1 predictions are deleted. $this->assertEquals(8, $DB->count_records('analytics_predictions')); // Calculations related to that context are deleted. $this->assertEmpty($DB->count_records('analytics_indicator_calc', ['contextid' => $coursecontext->id])); // The deleted context prediction actions are deleted as well. $this->assertEquals(0, $DB->count_records('analytics_prediction_actions')); } /** * Test delete a user. * * @return null */ public function test_delete_user_data(): void { global $DB; $usercontexts = provider::get_contexts_for_userid($this->u3->id); $contextlist = new \core_privacy\local\request\approved_contextlist($this->u3, 'core_analytics', $usercontexts->get_contextids()); provider::delete_data_for_user($contextlist); // The site level prediction for u3 was deleted. $this->assertEquals(9, $DB->count_records('analytics_predictions')); $this->assertEquals(0, $DB->count_records('analytics_prediction_actions')); $usercontexts = provider::get_contexts_for_userid($this->u1->id); $contextlist = new \core_privacy\local\request\approved_contextlist($this->u1, 'core_analytics', $usercontexts->get_contextids()); provider::delete_data_for_user($contextlist); // We have nothing for u1. $this->assertEquals(9, $DB->count_records('analytics_predictions')); $usercontexts = provider::get_contexts_for_userid($this->u4->id); $contextlist = new \core_privacy\local\request\approved_contextlist($this->u4, 'core_analytics', $usercontexts->get_contextids()); provider::delete_data_for_user($contextlist); $this->assertEquals(6, $DB->count_records('analytics_predictions')); } /** * Test deleting multiple users in a context. */ public function test_delete_data_for_users(): void { global $DB; $component = 'core_analytics'; $course1context = \context_course::instance($this->c1->id); $course2context = \context_course::instance($this->c2->id); $systemcontext = \context_system::instance(); // Ensure all records exist in expected contexts. $expectedcontexts = [$course1context->id, $course2context->id, $systemcontext->id]; sort($expectedcontexts); $actualcontexts = [ $this->u1->id => provider::get_contexts_for_userid($this->u1->id)->get_contextids(), $this->u2->id => provider::get_contexts_for_userid($this->u2->id)->get_contextids(), $this->u3->id => provider::get_contexts_for_userid($this->u3->id)->get_contextids(), $this->u4->id => provider::get_contexts_for_userid($this->u4->id)->get_contextids(), $this->u5->id => provider::get_contexts_for_userid($this->u5->id)->get_contextids(), $this->u6->id => provider::get_contexts_for_userid($this->u6->id)->get_contextids(), $this->u7->id => provider::get_contexts_for_userid($this->u7->id)->get_contextids(), $this->u8->id => provider::get_contexts_for_userid($this->u8->id)->get_contextids(), $this->u9->id => provider::get_contexts_for_userid($this->u9->id)->get_contextids(), $this->u10->id => provider::get_contexts_for_userid($this->u10->id)->get_contextids(), ]; foreach ($actualcontexts as $userid => $unused) { sort($actualcontexts[$userid]); $this->assertEquals($expectedcontexts, $actualcontexts[$userid]); } // Test initial record counts are as expected. $this->assertEquals(12, $DB->count_records('analytics_predictions')); $this->assertEquals(1, $DB->count_records('analytics_prediction_actions')); $this->assertEquals(32, $DB->count_records('analytics_indicator_calc')); // Delete u1 and u3 from system context. $approveduserids = [$this->u1->id, $this->u3->id]; $approvedlist = new approved_userlist($systemcontext, $component, $approveduserids); provider::delete_data_for_users($approvedlist); // Ensure u1 and u3 system context data deleted only. $expectedcontexts = [ $this->u1->id => [$course1context->id, $course2context->id], $this->u2->id => [$systemcontext->id, $course1context->id, $course2context->id], $this->u3->id => [$course1context->id, $course2context->id], $this->u4->id => [$systemcontext->id, $course1context->id, $course2context->id], $this->u5->id => [$systemcontext->id, $course1context->id, $course2context->id], $this->u6->id => [$systemcontext->id, $course1context->id, $course2context->id], $this->u7->id => [$systemcontext->id, $course1context->id, $course2context->id], $this->u8->id => [$systemcontext->id, $course1context->id, $course2context->id], $this->u9->id => [$systemcontext->id, $course1context->id, $course2context->id], $this->u10->id => [$systemcontext->id, $course1context->id, $course2context->id], ]; $actualcontexts = [ $this->u1->id => provider::get_contexts_for_userid($this->u1->id)->get_contextids(), $this->u2->id => provider::get_contexts_for_userid($this->u2->id)->get_contextids(), $this->u3->id => provider::get_contexts_for_userid($this->u3->id)->get_contextids(), $this->u4->id => provider::get_contexts_for_userid($this->u4->id)->get_contextids(), $this->u5->id => provider::get_contexts_for_userid($this->u5->id)->get_contextids(), $this->u6->id => provider::get_contexts_for_userid($this->u6->id)->get_contextids(), $this->u7->id => provider::get_contexts_for_userid($this->u7->id)->get_contextids(), $this->u8->id => provider::get_contexts_for_userid($this->u8->id)->get_contextids(), $this->u9->id => provider::get_contexts_for_userid($this->u9->id)->get_contextids(), $this->u10->id => provider::get_contexts_for_userid($this->u10->id)->get_contextids(), ]; foreach ($actualcontexts as $userid => $unused) { sort($expectedcontexts[$userid]); sort($actualcontexts[$userid]); $this->assertEquals($expectedcontexts[$userid], $actualcontexts[$userid]); } // Test expected number of records have been deleted. $this->assertEquals(11, $DB->count_records('analytics_predictions')); $this->assertEquals(1, $DB->count_records('analytics_prediction_actions')); $this->assertEquals(30, $DB->count_records('analytics_indicator_calc')); // Delete for all 8 users in course 2 context. $approveduserids = [$this->u1->id, $this->u2->id, $this->u3->id, $this->u4->id, $this->u5->id, $this->u6->id, $this->u7->id, $this->u8->id, $this->u9->id, $this->u10->id]; $approvedlist = new approved_userlist($course2context, $component, $approveduserids); provider::delete_data_for_users($approvedlist); // Ensure all course 2 context data deleted for all 4 users. $expectedcontexts = [ $this->u1->id => [$course1context->id], $this->u2->id => [$systemcontext->id, $course1context->id], $this->u3->id => [$course1context->id], $this->u4->id => [$systemcontext->id, $course1context->id], $this->u5->id => [$systemcontext->id, $course1context->id], $this->u6->id => [$systemcontext->id, $course1context->id], $this->u7->id => [$systemcontext->id, $course1context->id], $this->u8->id => [$systemcontext->id, $course1context->id], $this->u9->id => [$systemcontext->id, $course1context->id], $this->u10->id => [$systemcontext->id, $course1context->id], ]; $actualcontexts = [ $this->u1->id => provider::get_contexts_for_userid($this->u1->id)->get_contextids(), $this->u2->id => provider::get_contexts_for_userid($this->u2->id)->get_contextids(), $this->u3->id => provider::get_contexts_for_userid($this->u3->id)->get_contextids(), $this->u4->id => provider::get_contexts_for_userid($this->u4->id)->get_contextids(), $this->u5->id => provider::get_contexts_for_userid($this->u5->id)->get_contextids(), $this->u6->id => provider::get_contexts_for_userid($this->u6->id)->get_contextids(), $this->u7->id => provider::get_contexts_for_userid($this->u7->id)->get_contextids(), $this->u8->id => provider::get_contexts_for_userid($this->u8->id)->get_contextids(), $this->u9->id => provider::get_contexts_for_userid($this->u9->id)->get_contextids(), $this->u10->id => provider::get_contexts_for_userid($this->u10->id)->get_contextids(), ]; foreach ($actualcontexts as $userid => $unused) { sort($actualcontexts[$userid]); sort($expectedcontexts[$userid]); $this->assertEquals($expectedcontexts[$userid], $actualcontexts[$userid]); } // Test expected number of records have been deleted. $this->assertEquals(7, $DB->count_records('analytics_predictions')); $this->assertEquals(1, $DB->count_records('analytics_prediction_actions')); $this->assertEquals(20, $DB->count_records('analytics_indicator_calc')); $approveduserids = [$this->u3->id]; $approvedlist = new approved_userlist($course1context, $component, $approveduserids); provider::delete_data_for_users($approvedlist); // Ensure all course 1 context data deleted for u3. $expectedcontexts = [ $this->u1->id => [$course1context->id], $this->u2->id => [$systemcontext->id, $course1context->id], $this->u3->id => [], $this->u4->id => [$systemcontext->id, $course1context->id], $this->u5->id => [$systemcontext->id, $course1context->id], $this->u6->id => [$systemcontext->id, $course1context->id], $this->u7->id => [$systemcontext->id, $course1context->id], $this->u8->id => [$systemcontext->id, $course1context->id], $this->u9->id => [$systemcontext->id, $course1context->id], $this->u10->id => [$systemcontext->id, $course1context->id], ]; $actualcontexts = [ $this->u1->id => provider::get_contexts_for_userid($this->u1->id)->get_contextids(), $this->u2->id => provider::get_contexts_for_userid($this->u2->id)->get_contextids(), $this->u3->id => provider::get_contexts_for_userid($this->u3->id)->get_contextids(), $this->u4->id => provider::get_contexts_for_userid($this->u4->id)->get_contextids(), $this->u5->id => provider::get_contexts_for_userid($this->u5->id)->get_contextids(), $this->u6->id => provider::get_contexts_for_userid($this->u6->id)->get_contextids(), $this->u7->id => provider::get_contexts_for_userid($this->u7->id)->get_contextids(), $this->u8->id => provider::get_contexts_for_userid($this->u8->id)->get_contextids(), $this->u9->id => provider::get_contexts_for_userid($this->u9->id)->get_contextids(), $this->u10->id => provider::get_contexts_for_userid($this->u10->id)->get_contextids(), ]; foreach ($actualcontexts as $userid => $unused) { sort($actualcontexts[$userid]); sort($expectedcontexts[$userid]); $this->assertEquals($expectedcontexts[$userid], $actualcontexts[$userid]); } // Test expected number of records have been deleted. $this->assertEquals(6, $DB->count_records('analytics_predictions')); $this->assertEquals(0, $DB->count_records('analytics_prediction_actions')); $this->assertEquals(19, $DB->count_records('analytics_indicator_calc')); } /** * Test export user data. * * @return null */ public function test_export_data(): void { global $DB; $system = \context_system::instance(); list($total, $predictions) = $this->model1->get_predictions($system); foreach ($predictions as $key => $prediction) { if ($prediction->get_prediction_data()->sampleid !== $this->u3->id) { $otheruserprediction = $prediction; break; } } $this->setUser($this->u3); $otheruserprediction->action_executed(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $this->model1->get_target()); $this->setAdminUser(); $this->export_context_data_for_user($this->u3->id, $system, 'core_analytics'); $writer = \core_privacy\local\request\writer::with_context($system); $this->assertTrue($writer->has_any_data()); $u3prediction = $DB->get_record('analytics_predictions', ['contextid' => $system->id, 'sampleid' => $this->u3->id]); $data = $writer->get_data([get_string('analytics', 'analytics'), get_string('privacy:metadata:analytics:predictions', 'analytics'), $u3prediction->id]); $this->assertEquals(get_string('adminhelplogs'), $data->target); $this->assertEquals(get_string('coresystem'), $data->context); $this->assertEquals('firstname first char is not A', $data->prediction); $u3calculation = $DB->get_record('analytics_indicator_calc', ['contextid' => $system->id, 'sampleid' => $this->u3->id]); $data = $writer->get_data([get_string('analytics', 'analytics'), get_string('privacy:metadata:analytics:indicatorcalc', 'analytics'), $u3calculation->id]); $this->assertEquals('Allow stealth activities', $data->indicator); $this->assertEquals(get_string('coresystem'), $data->context); $this->assertEquals(get_string('yes'), $data->calculation); $sql = "SELECT apa.id FROM {analytics_prediction_actions} apa JOIN {analytics_predictions} ap ON ap.id = apa.predictionid WHERE ap.contextid = :contextid AND apa.userid = :userid AND ap.modelid = :modelid"; $params = ['contextid' => $system->id, 'userid' => $this->u3->id, 'modelid' => $this->model1->get_id()]; $u3action = $DB->get_record_sql($sql, $params); $data = $writer->get_data([get_string('analytics', 'analytics'), get_string('privacy:metadata:analytics:predictionactions', 'analytics'), $u3action->id]); $this->assertEquals(get_string('adminhelplogs'), $data->target); $this->assertEquals(get_string('coresystem'), $data->context); $this->assertEquals(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $data->action); } } tests/community_of_inquiry_activities_completed_by_test.php000064400000034216152266351020020730 0ustar00. namespace core_analytics; use advanced_testcase; use ReflectionClass; use ReflectionMethod; use stdClass; /** * Unit tests for activities completed by classification. * * @package core_analytics * @covers \core_analytics\local\indicator\community_of_inquiry_activity * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class community_of_inquiry_activities_completed_by_test extends advanced_testcase { /** * availability_levels * * @return array */ public static function availability_levels(): array { return array( 'activity' => array('activity'), 'section' => array('section'), ); } /** * test_get_activities_with_availability * * @dataProvider availability_levels * @param string $availabilitylevel * @return void */ public function test_get_activities_with_availability($availabilitylevel): void { list($course, $stu1) = $this->setup_course(); // Forum1 is ignored as section 0 does not count. $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course->id)); $modinfo = get_fast_modinfo($course, $stu1->id); $cm = $modinfo->get_cm($forum->cmid); if ($availabilitylevel === 'activity') { $availabilityinfo = new \core_availability\info_module($cm); } else if ($availabilitylevel === 'section') { $availabilityinfo = new \core_availability\info_section($cm->get_modinfo()->get_section_info($cm->sectionnum)); } else { $this->fail('Unsupported availability level'); } $fromtime = strtotime('2015-10-22 00:00:00 GMT'); $untiltime = strtotime('2015-10-24 00:00:00 GMT'); $structure = (object)array('op' => '|', 'show' => true, 'c' => array( (object)array('type' => 'date', 'd' => '<', 't' => $untiltime), (object)array('type' => 'date', 'd' => '>=', 't' => $fromtime) )); $method = new ReflectionMethod($availabilityinfo, 'set_in_database'); $method->invoke($availabilityinfo, json_encode($structure)); $this->setUser($stu1); // Reset modinfo we also want coursemodinfo cache definition to be cleared. get_fast_modinfo($course, $stu1->id, true); rebuild_course_cache($course->id, true); $modinfo = get_fast_modinfo($course, $stu1->id); $cm = $modinfo->get_cm($forum->cmid); $course = new \core_analytics\course($course); list($indicator, $method) = $this->instantiate_indicator('mod_forum', $course); // Condition from after provided end time. $this->assertCount(0, $method->invoke($indicator, strtotime('2015-10-20 00:00:00 GMT'), strtotime('2015-10-21 00:00:00 GMT'), $stu1)); // Condition until before provided start time $this->assertCount(0, $method->invoke($indicator, strtotime('2015-10-25 00:00:00 GMT'), strtotime('2015-10-26 00:00:00 GMT'), $stu1)); // Condition until after provided end time. $this->assertCount(0, $method->invoke($indicator, strtotime('2015-10-22 00:00:00 GMT'), strtotime('2015-10-23 00:00:00 GMT'), $stu1)); // Condition until after provided start time and before provided end time. $this->assertCount(1, $method->invoke($indicator, strtotime('2015-10-22 00:00:00 GMT'), strtotime('2015-10-25 00:00:00 GMT'), $stu1)); } /** * test_get_activities_with_weeks * * @return void */ public function test_get_activities_with_weeks(): void { $startdate = gmmktime('0', '0', '0', 10, 24, 2015); $record = array( 'format' => 'weeks', 'numsections' => 4, 'startdate' => $startdate, ); list($course, $stu1) = $this->setup_course($record); // Forum1 is ignored as section 0 does not count. $forum1 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id), array('section' => 0)); $forum2 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id), array('section' => 1)); $forum3 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id), array('section' => 2)); $forum4 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id), array('section' => 4)); $forum5 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id), array('section' => 4)); $course = new \core_analytics\course($course); list($indicator, $method) = $this->instantiate_indicator('mod_forum', $course); $this->setUser($stu1); $first = $startdate; $second = $startdate + WEEKSECS; $third = $startdate + (WEEKSECS * 2); $forth = $startdate + (WEEKSECS * 3); $this->assertCount(2, $method->invoke($indicator, $first, $first + WEEKSECS, $stu1)); $this->assertCount(1, $method->invoke($indicator, $second, $second + WEEKSECS, $stu1)); $this->assertCount(0, $method->invoke($indicator, $third, $third + WEEKSECS, $stu1)); $this->assertCount(2, $method->invoke($indicator, $forth, $forth + WEEKSECS, $stu1)); } /** * test_get_activities_by_section * * @return void */ public function test_get_activities_by_section(): void { // This makes debugging easier, sorry WA's +8 :). $this->setTimezone('UTC'); // 1 year. $startdate = gmmktime('0', '0', '0', 10, 24, 2015); $enddate = gmmktime('0', '0', '0', 10, 24, 2016); $numsections = 12; $record = array( 'format' => 'topics', 'numsections' => $numsections, 'startdate' => $startdate, 'enddate' => $enddate ); list($course, $stu1) = $this->setup_course($record); // Forum1 is ignored as section 0 does not count. $forum1 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id), array('section' => 0)); $forum2 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id), array('section' => 1)); $forum3 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id), array('section' => 4)); $forum4 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id), array('section' => 8)); $forum5 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id), array('section' => 10)); $forum6 = $this->getDataGenerator()->create_module('forum', array('course' => $course->id), array('section' => 12)); $course = new \core_analytics\course($course); list($indicator, $method) = $this->instantiate_indicator('mod_forum', $course); $this->setUser($stu1); // Split the course in quarters. $duration = ($enddate - $startdate) / 4; $first = $startdate; $second = $startdate + $duration; $third = $startdate + ($duration * 2); $forth = $startdate + ($duration * 3); $this->assertCount(1, $method->invoke($indicator, $first, $first + $duration, $stu1)); $this->assertCount(1, $method->invoke($indicator, $second, $second + $duration, $stu1)); $this->assertCount(1, $method->invoke($indicator, $third, $third + $duration, $stu1)); $this->assertCount(2, $method->invoke($indicator, $forth, $forth + $duration, $stu1)); // Split the course in as many parts as sections. $duration = ($enddate - $startdate) / $numsections; for ($i = 1; $i <= $numsections; $i++) { // The -1 because section 1 start represents the course start. $timeranges[$i] = $startdate + ($duration * ($i - 1)); } $this->assertCount(1, $method->invoke($indicator, $timeranges[1], $timeranges[1] + $duration, $stu1)); $this->assertCount(1, $method->invoke($indicator, $timeranges[4], $timeranges[4] + $duration, $stu1)); $this->assertCount(1, $method->invoke($indicator, $timeranges[8], $timeranges[8] + $duration, $stu1)); $this->assertCount(1, $method->invoke($indicator, $timeranges[10], $timeranges[10] + $duration, $stu1)); $this->assertCount(1, $method->invoke($indicator, $timeranges[12], $timeranges[12] + $duration, $stu1)); // Nothing here. $this->assertCount(0, $method->invoke($indicator, $timeranges[2], $timeranges[2] + $duration, $stu1)); $this->assertCount(0, $method->invoke($indicator, $timeranges[3], $timeranges[3] + $duration, $stu1)); } /** * test_get_activities_with_specific_restrictions * * @return void */ public function test_get_activities_with_specific_restrictions(): void { list($course, $stu1) = $this->setup_course(); $end = strtotime('2015-10-24 00:00:00 GMT'); // 1 with time close, one without. $params = array('course' => $course->id); $assign1 = $this->getDataGenerator()->create_module('assign', $params); $params['duedate'] = $end; $assign2 = $this->getDataGenerator()->create_module('assign', $params); // 1 with time close, one without. $params = array('course' => $course->id); $choice1 = $this->getDataGenerator()->create_module('choice', $params); $params['timeclose'] = $end; $choice1 = $this->getDataGenerator()->create_module('choice', $params); // 1 with time close, one without. $params = array('course' => $course->id); $data1 = $this->getDataGenerator()->create_module('data', $params); $params['timeavailableto'] = $end; $data1 = $this->getDataGenerator()->create_module('data', $params); // 1 with time close, one without. $params = array('course' => $course->id); $feedback1 = $this->getDataGenerator()->create_module('feedback', $params); $params['timeclose'] = $end; $feedback1 = $this->getDataGenerator()->create_module('feedback', $params); // 1 with time close, one without. $params = array('course' => $course->id); $lesson1 = $this->getDataGenerator()->create_module('lesson', $params); $params['deadline'] = $end; $lesson1 = $this->getDataGenerator()->create_module('lesson', $params); // 1 with time close, one without. $params = array('course' => $course->id); $quiz1 = $this->getDataGenerator()->create_module('quiz', $params); $params['timeclose'] = $end; $quiz1 = $this->getDataGenerator()->create_module('quiz', $params); // 1 with time close, one without. $params = array('course' => $course->id); $scorm1 = $this->getDataGenerator()->create_module('scorm', $params); $params['timeclose'] = $end; $scorm1 = $this->getDataGenerator()->create_module('scorm', $params); // 1 with time close, one without. $params = array('course' => $course->id); $workshop1 = $this->getDataGenerator()->create_module('workshop', $params); $params['submissionend'] = $end; $workshop1 = $this->getDataGenerator()->create_module('workshop', $params); $course = new \core_analytics\course($course); $activitytypes = array('mod_assign', 'mod_choice', 'mod_data', 'mod_feedback', 'mod_lesson', 'mod_quiz', 'mod_scorm', 'mod_workshop'); foreach ($activitytypes as $activitytype) { list($indicator, $method) = $this->instantiate_indicator($activitytype, $course); $message = $activitytype . ' activity type returned activities do not match expected size'; $this->assertCount(0, $method->invoke($indicator, strtotime('2015-10-20 00:00:00 GMT'), strtotime('2015-10-21 00:00:00 GMT'), $stu1), $message); $this->assertCount(0, $method->invoke($indicator, strtotime('2015-10-25 00:00:00 GMT'), strtotime('2015-10-26 00:00:00 GMT'), $stu1), $message); $this->assertCount(1, $method->invoke($indicator, strtotime('2015-10-22 00:00:00 GMT'), strtotime('2015-10-25 00:00:00 GMT'), $stu1), $message); } } /** * setup_course * * @param stdClass $courserecord * @return array */ protected function setup_course($courserecord = null) { global $CFG; $this->resetAfterTest(true); $this->setAdminUser(); $CFG->enableavailability = true; $course = $this->getDataGenerator()->create_course($courserecord); $stu1 = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($stu1->id, $course->id, 'student'); return array($course, $stu1); } /** * Returns the module cognitive depth indicator and the reflection method. * * @param string $modulename * @param \core_analytics\course $course * @return array */ private function instantiate_indicator($modulename, \core_analytics\course $course) { $classname = '\\' . $modulename . '\analytics\indicator\cognitive_depth'; $indicator = new $classname(); $class = new ReflectionClass($indicator); $property = $class->getProperty('course'); $property->setValue($indicator, $course); $method = new ReflectionMethod($indicator, 'get_activities'); return array($indicator, $method); } } tests/stats_test.php000064400000014772152266351020010631 0ustar00. namespace core_analytics; use core_analytics\tests\mlbackend_helper_trait; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/fixtures/test_indicator_fullname.php'); require_once(__DIR__ . '/fixtures/test_target_shortname.php'); /** * Unit tests for the analytics stats. * * @package core_analytics * @category test * @copyright 2019 David Mudrák * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class stats_test extends \advanced_testcase { use mlbackend_helper_trait; /** * Set up the test environment. */ public function setUp(): void { parent::setUp(); $this->resetAfterTest(); $this->setAdminUser(); } /** * Test the {@link \core_analytics\stats::enabled_models()} implementation. */ public function test_enabled_models(): void { // By default, sites have {@link \core_course\analytics\target\no_teaching} and // {@link \core_user\analytics\target\upcoming_activities_due} enabled. $this->assertEquals(4, \core_analytics\stats::enabled_models()); $model = \core_analytics\model::create( \core_analytics\manager::get_target('\core_course\analytics\target\course_dropout'), [ \core_analytics\manager::get_indicator('\core\analytics\indicator\any_write_action'), ] ); // Purely adding a new model does not make it included in the stats. $this->assertEquals(4, \core_analytics\stats::enabled_models()); // New models must be enabled to have them counted. $model->enable('\core\analytics\time_splitting\quarters'); $this->assertEquals(5, \core_analytics\stats::enabled_models()); } /** * Test the {@link \core_analytics\stats::predictions()} implementation. */ public function test_predictions(): void { if (!self::is_mlbackend_python_configured()) { $this->markTestSkipped('mlbackend_python is not configured.'); } $model = \core_analytics\model::create( \core_analytics\manager::get_target('test_target_shortname'), [ \core_analytics\manager::get_indicator('test_indicator_fullname'), ] ); $model->enable('\core\analytics\time_splitting\no_splitting'); // Train the model. $this->getDataGenerator()->create_course(['shortname' => 'a', 'fullname' => 'a', 'visible' => 1]); $this->getDataGenerator()->create_course(['shortname' => 'b', 'fullname' => 'b', 'visible' => 1]); $model->train(); // No predictions yet. $this->assertEquals(0, \core_analytics\stats::predictions()); // Get one new prediction. $this->getDataGenerator()->create_course(['shortname' => 'aa', 'fullname' => 'aa', 'visible' => 0]); $result = $model->predict(); $this->assertEquals(1, count($result->predictions)); $this->assertEquals(1, \core_analytics\stats::predictions()); // Nothing changes if there is no new prediction. $result = $model->predict(); $this->assertFalse(isset($result->predictions)); $this->assertEquals(1, \core_analytics\stats::predictions()); // Get two more predictions, we have three in total now. $this->getDataGenerator()->create_course(['shortname' => 'bb', 'fullname' => 'bb', 'visible' => 0]); $this->getDataGenerator()->create_course(['shortname' => 'cc', 'fullname' => 'cc', 'visible' => 0]); $result = $model->predict(); $this->assertEquals(2, count($result->predictions)); $this->assertEquals(3, \core_analytics\stats::predictions()); } /** * Test the {@link \core_analytics\stats::actions()} and {@link \core_analytics\stats::actions_not_useful()} implementation. */ public function test_actions(): void { global $DB; if (!self::is_mlbackend_python_configured()) { $this->markTestSkipped('mlbackend_python is not configured.'); } $model = \core_analytics\model::create( \core_analytics\manager::get_target('test_target_shortname'), [ \core_analytics\manager::get_indicator('test_indicator_fullname'), ] ); $model->enable('\core\analytics\time_splitting\no_splitting'); // Train the model. $this->getDataGenerator()->create_course(['shortname' => 'a', 'fullname' => 'a', 'visible' => 1]); $this->getDataGenerator()->create_course(['shortname' => 'b', 'fullname' => 'b', 'visible' => 1]); $model->train(); // Generate two predictions. $this->getDataGenerator()->create_course(['shortname' => 'aa', 'fullname' => 'aa', 'visible' => 0]); $this->getDataGenerator()->create_course(['shortname' => 'bb', 'fullname' => 'bb', 'visible' => 0]); $model->predict(); list($p1, $p2) = array_values($DB->get_records('analytics_predictions')); $p1 = new \core_analytics\prediction($p1, []); $p2 = new \core_analytics\prediction($p2, []); // No actions executed at the start. $this->assertEquals(0, \core_analytics\stats::actions()); $this->assertEquals(0, \core_analytics\stats::actions_not_useful()); // The user has acknowledged the first prediction. $p1->action_executed(\core_analytics\prediction::ACTION_FIXED, $model->get_target()); $this->assertEquals(1, \core_analytics\stats::actions()); $this->assertEquals(0, \core_analytics\stats::actions_not_useful()); // The user has marked the other prediction as not useful. $p2->action_executed(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $model->get_target()); $this->assertEquals(2, \core_analytics\stats::actions()); $this->assertEquals(1, \core_analytics\stats::actions_not_useful()); } } tests/classes/mlbackend_helper_trait.php000064400000005446152266351020014551 0ustar00. namespace core_analytics\tests; use phpunit_util; /** * A trait to check machine learning configurations. * * @package core_analytics * @category test * @copyright 2024 David Woloszyn * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ trait mlbackend_helper_trait { /** * Check if mlbackend_python is configured. * * @return bool */ public static function is_mlbackend_python_configured(): bool { if (defined('TEST_MLBACKEND_PYTHON_HOST') && defined('TEST_MLBACKEND_PYTHON_PORT') && defined('TEST_MLBACKEND_PYTHON_USERNAME') && defined('TEST_MLBACKEND_PYTHON_USERNAME')) { return true; } return false; } /** * Generate test courses. * * @param int $ncourses Number of courses to generate. * @param array $params Additional parameters for the courses. * @param bool $ismulticlass Whether to generate courses for multi-classification. */ public function generate_courses( int $ncourses, array $params = [], bool $ismulticlass = false, ): void { $params = $params + [ 'startdate' => mktime(0, 0, 0, 10, 24, 2015), 'enddate' => mktime(0, 0, 0, 2, 24, 2016), ]; for ($i = 0; $i < $ncourses; $i++) { $name = 'a' . random_string(10); $courseparams = ['shortname' => $name, 'fullname' => $name] + $params; phpunit_util::get_data_generator()->create_course($courseparams); } for ($i = 0; $i < $ncourses; $i++) { $name = 'b' . random_string(10); $courseparams = ['shortname' => $name, 'fullname' => $name] + $params; phpunit_util::get_data_generator()->create_course($courseparams); } if ($ismulticlass) { for ($i = 0; $i < $ncourses; $i++) { $name = 'c' . random_string(10); $courseparams = ['shortname' => $name, 'fullname' => $name] + $params; phpunit_util::get_data_generator()->create_course($courseparams); } } } } tests/analysis_test.php000064400000007074152266351020011313 0ustar00. namespace core_analytics; /** * Unit tests for the analysis class. * * @package core_analytics * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class analysis_test extends \advanced_testcase { /** * Test fill_firstanalyses_cache. * @return null */ public function test_fill_firstanalyses_cache(): void { require_once(self::get_fixture_path(__NAMESPACE__, 'test_timesplitting_upcoming_seconds.php')); $this->resetAfterTest(); $modelid = 1; $params = ['startdate' => (new \DateTimeImmutable('-5 seconds'))->getTimestamp()]; $course1 = $this->getDataGenerator()->create_course($params); $course2 = $this->getDataGenerator()->create_course($params); $analysable1 = new \core_analytics\course($course1); $afewsecsago = time() - 5; $earliest = $afewsecsago - 1; $this->insert_used($modelid, $course1->id, 'training', $afewsecsago); // Course2 processed after course1. $this->insert_used($modelid, $course2->id, 'training', $afewsecsago + 1); // After the first process involving course1. $this->insert_used($modelid, $course1->id, 'prediction', $afewsecsago + 5); $firstanalyses = \core_analytics\analysis::fill_firstanalyses_cache($modelid); $this->assertCount(2, $firstanalyses); $this->assertEquals($afewsecsago, $firstanalyses[$modelid . '_' . $course1->id]); $this->assertEquals($afewsecsago + 1, $firstanalyses[$modelid . '_' . $course2->id]); // The cached elements get refreshed. $this->insert_used($modelid, $course1->id, 'prediction', $earliest); $firstanalyses = \core_analytics\analysis::fill_firstanalyses_cache($modelid, $course1->id); $this->assertCount(1, $firstanalyses); $this->assertEquals($earliest, $firstanalyses[$modelid . '_' . $course1->id]); // Upcoming periodic time-splitting methods can read and process the cached data. $seconds = new \test_timesplitting_upcoming_seconds(); $seconds->set_modelid($modelid); $seconds->set_analysable($analysable1); // The generated ranges should start from the cached firstanalysis value, which is $earliest. $ranges = $seconds->get_all_ranges(); $this->assertGreaterThanOrEqual(7, count($ranges)); $firstrange = reset($ranges); $this->assertEquals($earliest, $firstrange['time']); } private function insert_used($modelid, $analysableid, $action, $timestamp) { global $DB; $obj = new \stdClass(); $obj->modelid = $modelid; $obj->action = $action; $obj->analysableid = $analysableid; $obj->firstanalysis = $timestamp; $obj->timeanalysed = $timestamp; $obj->id = $DB->insert_record('analytics_used_analysables', $obj); } } tests/model_test.php000064400000060323152266351020010564 0ustar00. /** * Unit tests for the model. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_analytics; use core_analytics\tests\mlbackend_helper_trait; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/fixtures/test_indicator_max.php'); require_once(__DIR__ . '/fixtures/test_indicator_min.php'); require_once(__DIR__ . '/fixtures/test_indicator_fullname.php'); require_once(__DIR__ . '/fixtures/test_target_shortname.php'); require_once(__DIR__ . '/fixtures/test_static_target_shortname.php'); require_once(__DIR__ . '/fixtures/test_target_course_level_shortname.php'); require_once(__DIR__ . '/fixtures/test_analysis.php'); /** * Unit tests for the model. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class model_test extends \advanced_testcase { use mlbackend_helper_trait; /** @var model Store Model. */ protected $model; /** @var \stdClass Store model object. */ protected $modelobj; public function setUp(): void { parent::setUp(); $this->resetAfterTest(); $this->setAdminUser(); $target = \core_analytics\manager::get_target('test_target_shortname'); $indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_fullname'); foreach ($indicators as $key => $indicator) { $indicators[$key] = \core_analytics\manager::get_indicator($indicator); } $this->model = testable_model::create($target, $indicators); $this->modelobj = $this->model->get_model_obj(); } public function test_enable(): void { $this->assertEquals(0, $this->model->get_model_obj()->enabled); $this->assertEquals(0, $this->model->get_model_obj()->trained); $this->assertEquals('', $this->model->get_model_obj()->timesplitting); $this->model->enable('\core\analytics\time_splitting\quarters'); $this->assertEquals(1, $this->model->get_model_obj()->enabled); $this->assertEquals(0, $this->model->get_model_obj()->trained); $this->assertEquals('\core\analytics\time_splitting\quarters', $this->model->get_model_obj()->timesplitting); } public function test_create(): void { $target = \core_analytics\manager::get_target('\core_course\analytics\target\course_dropout'); $indicators = array( \core_analytics\manager::get_indicator('\core\analytics\indicator\any_write_action'), \core_analytics\manager::get_indicator('\core\analytics\indicator\read_actions') ); $model = \core_analytics\model::create($target, $indicators); $this->assertInstanceOf('\core_analytics\model', $model); } /** * test_delete */ public function test_delete(): void { global $DB; if (!self::is_mlbackend_python_configured()) { $this->markTestSkipped('mlbackend_python is not configured.'); } $this->resetAfterTest(true); set_config('enabled_stores', 'logstore_standard', 'tool_log'); // Create some courses. $this->generate_courses(2, ['visible' => 0]); $this->generate_courses(2, ['visible' => 1]); $this->model->enable('\core\analytics\time_splitting\single_range'); $this->model->train(); $this->model->predict(); // Fake evaluation results record to check that it is actually deleted. $this->add_fake_log(); // Generate a prediction action to confirm that it is deleted when there is an important update. $predictions = $DB->get_records('analytics_predictions'); $prediction = reset($predictions); $prediction = new \core_analytics\prediction($prediction, array('whatever' => 'not used')); $prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target()); $this->model->delete(); $this->assertEmpty($DB->count_records('analytics_models', array('id' => $this->modelobj->id))); $this->assertEmpty($DB->count_records('analytics_models_log', array('modelid' => $this->modelobj->id))); $this->assertEmpty($DB->count_records('analytics_predictions')); $this->assertEmpty($DB->count_records('analytics_prediction_actions')); $this->assertEmpty($DB->count_records('analytics_train_samples')); $this->assertEmpty($DB->count_records('analytics_predict_samples')); $this->assertEmpty($DB->count_records('analytics_used_files')); set_config('enabled_stores', '', 'tool_log'); get_log_manager(true); } /** * test_clear */ public function test_clear(): void { global $DB; if (!self::is_mlbackend_python_configured()) { $this->markTestSkipped('mlbackend_python is not configured.'); } $this->resetAfterTest(true); set_config('enabled_stores', 'logstore_standard', 'tool_log'); // Create some courses. $this->generate_courses(2, ['visible' => 0]); $this->generate_courses(2, ['visible' => 1]); $this->model->enable('\core\analytics\time_splitting\single_range'); $this->model->train(); $this->model->predict(); // Fake evaluation results record to check that it is actually deleted. $this->add_fake_log(); // Generate a prediction action to confirm that it is deleted when there is an important update. $predictions = $DB->get_records('analytics_predictions'); $prediction = reset($predictions); $prediction = new \core_analytics\prediction($prediction, array('whatever' => 'not used')); $prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target()); $modelversionoutputdir = $this->model->get_output_dir(); $this->assertTrue(is_dir($modelversionoutputdir)); // Update to an empty time splitting method to force model::clear execution. $this->model->clear(); // Check that most of the stuff got deleted. $this->assertEquals(1, $DB->count_records('analytics_models', array('id' => $this->modelobj->id))); $this->assertEquals(1, $DB->count_records('analytics_models_log', array('modelid' => $this->modelobj->id))); $this->assertEmpty($DB->count_records('analytics_predictions')); $this->assertEmpty($DB->count_records('analytics_prediction_actions')); $this->assertEmpty($DB->count_records('analytics_train_samples')); $this->assertEmpty($DB->count_records('analytics_predict_samples')); $this->assertEmpty($DB->count_records('analytics_used_files')); // Check that the model is marked as not trained after clearing (as it is not a static one). $this->assertEquals(0, $DB->get_field('analytics_models', 'trained', array('id' => $this->modelobj->id))); set_config('enabled_stores', '', 'tool_log'); get_log_manager(true); } /** * Test behaviour of {\core_analytics\model::clear()} for static models. */ public function test_clear_static(): void { global $DB; $statictarget = new \test_static_target_shortname(); $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max'); $model = \core_analytics\model::create($statictarget, $indicators, '\core\analytics\time_splitting\quarters'); $modelobj = $model->get_model_obj(); // Static models are always considered trained. $this->assertEquals(1, $DB->get_field('analytics_models', 'trained', array('id' => $modelobj->id))); $model->clear(); // Check that the model is still marked as trained even after clearing. $this->assertEquals(1, $DB->get_field('analytics_models', 'trained', array('id' => $modelobj->id))); } public function test_model_manager(): void { $this->assertCount(3, $this->model->get_indicators()); $this->assertInstanceOf('\core_analytics\local\target\binary', $this->model->get_target()); // Using evaluation as the model is not yet enabled. $this->model->init_analyser(array('evaluation' => true)); $this->assertInstanceOf('\core_analytics\local\analyser\base', $this->model->get_analyser()); $this->model->enable('\core\analytics\time_splitting\quarters'); $this->assertInstanceOf('\core\analytics\analyser\site_courses', $this->model->get_analyser()); } public function test_output_dir(): void { $dir = make_request_directory(); set_config('modeloutputdir', $dir, 'analytics'); $modeldir = $dir . DIRECTORY_SEPARATOR . $this->modelobj->id . DIRECTORY_SEPARATOR . $this->modelobj->version; $this->assertEquals($modeldir, $this->model->get_output_dir()); $this->assertEquals($modeldir . DIRECTORY_SEPARATOR . 'testing', $this->model->get_output_dir(array('testing'))); } public function test_unique_id(): void { global $DB; $originaluniqueid = $this->model->get_unique_id(); // Same id across instances. $this->model = new testable_model($this->modelobj); $this->assertEquals($originaluniqueid, $this->model->get_unique_id()); // We will restore it later. $originalversion = $this->modelobj->version; // Generates a different id if timemodified changes. $this->modelobj->version = $this->modelobj->version + 10; $DB->update_record('analytics_models', $this->modelobj); $this->model = new testable_model($this->modelobj); $this->assertNotEquals($originaluniqueid, $this->model->get_unique_id()); // Restore original timemodified to continue testing. $this->modelobj->version = $originalversion; $DB->update_record('analytics_models', $this->modelobj); // Same when updating through an action that changes the model. $this->model = new testable_model($this->modelobj); $this->model->mark_as_trained(); $this->assertEquals($originaluniqueid, $this->model->get_unique_id()); // Wait for the current timestamp to change. $this->waitForSecond(); $this->model->enable('\core\analytics\time_splitting\deciles'); $this->assertNotEquals($originaluniqueid, $this->model->get_unique_id()); $uniqueid = $this->model->get_unique_id(); // Wait for the current timestamp to change. $this->waitForSecond(); $this->model->enable('\core\analytics\time_splitting\quarters'); $this->assertNotEquals($originaluniqueid, $this->model->get_unique_id()); $this->assertNotEquals($uniqueid, $this->model->get_unique_id()); } /** * test_exists * * @return void */ public function test_exists(): void { $target = \core_analytics\manager::get_target('\core_course\analytics\target\no_teaching'); $this->assertTrue(\core_analytics\model::exists($target)); foreach (\core_analytics\manager::get_all_models() as $model) { $model->delete(); } $this->assertFalse(\core_analytics\model::exists($target)); } /** * test_model_timelimit * * @return null */ public function test_model_timelimit(): void { global $DB; set_config('modeltimelimit', 2, 'analytics'); $courses = array(); for ($i = 0; $i < 5; $i++) { $course = $this->getDataGenerator()->create_course(); $analysable = new \core_analytics\course($course); $courses[$analysable->get_id()] = $course; } $target = new \test_target_course_level_shortname(); $analyser = new \core\analytics\analyser\courses(1, $target, [], [], []); $result = new \core_analytics\local\analysis\result_array(1, false, []); $analysis = new \test_analysis($analyser, false, $result); // Each analysable element takes 0.5 secs minimum (test_analysis), so the max (and likely) number of analysable // elements that will be processed is 2. $analysis->run(); $params = array('modelid' => 1, 'action' => 'prediction'); $this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params)); $analysis->run(); $this->assertLessThanOrEqual(4, $DB->count_records('analytics_used_analysables', $params)); // Check that analysable elements have been processed following the analyser order // (course->sortorder here). We can not check this nicely after next get_unlabelled_data round // because the first analysed element will be analysed again. $analysedelems = $DB->get_records('analytics_used_analysables', $params, 'timeanalysed ASC'); // Just a default for the first checked element. $last = (object)['sortorder' => PHP_INT_MAX]; foreach ($analysedelems as $analysed) { if ($courses[$analysed->analysableid]->sortorder > $last->sortorder) { $this->fail('Analysable elements have not been analysed sorted by course sortorder.'); } $last = $courses[$analysed->analysableid]; } // No time limit now to process the rest. set_config('modeltimelimit', 1000, 'analytics'); $analysis->run(); $this->assertEquals(5, $DB->count_records('analytics_used_analysables', $params)); // New analysable elements are immediately pulled. $this->getDataGenerator()->create_course(); $analysis->run(); $this->assertEquals(6, $DB->count_records('analytics_used_analysables', $params)); // Training and prediction data do not get mixed. $result = new \core_analytics\local\analysis\result_array(1, false, []); $analysis = new \test_analysis($analyser, false, $result); $analysis->run(); $params = array('modelid' => 1, 'action' => 'training'); $this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params)); } /** * Test model_config::get_class_component. */ public function test_model_config_get_class_component(): void { $this->assertEquals('core', \core_analytics\model_config::get_class_component('\\core\\analytics\\indicator\\read_actions')); $this->assertEquals('core', \core_analytics\model_config::get_class_component('core\\analytics\\indicator\\read_actions')); $this->assertEquals('core', \core_analytics\model_config::get_class_component('\\core_course\\analytics\\indicator\\completion_enabled')); $this->assertEquals('mod_forum', \core_analytics\model_config::get_class_component('\\mod_forum\\analytics\\indicator\\cognitive_depth')); $this->assertEquals('core', \core_analytics\model_config::get_class_component('\\core_class')); } /** * Test that import_model import models' configurations. */ public function test_import_model_config(): void { if (!self::is_mlbackend_python_configured()) { $this->markTestSkipped('mlbackend_python is not configured.'); } $this->model->enable('\\core\\analytics\\time_splitting\\quarters'); $zipfilepath = $this->model->export_model('yeah-config.zip'); $this->modelobj = $this->model->get_model_obj(); $importedmodelobj = \core_analytics\model::import_model($zipfilepath)->get_model_obj(); $this->assertSame($this->modelobj->target, $importedmodelobj->target); $this->assertSame($this->modelobj->indicators, $importedmodelobj->indicators); $this->assertSame($this->modelobj->timesplitting, $importedmodelobj->timesplitting); $predictionsprocessor = $this->model->get_predictions_processor(); $this->assertSame('\\' . get_class($predictionsprocessor), $importedmodelobj->predictionsprocessor); } /** * Test can export configuration */ public function test_can_export_configuration(): void { // No time splitting method. $this->assertFalse($this->model->can_export_configuration()); $this->model->enable('\\core\\analytics\\time_splitting\\quarters'); $this->assertTrue($this->model->can_export_configuration()); $this->model->update(true, [], false); $this->assertFalse($this->model->can_export_configuration()); $statictarget = new \test_static_target_shortname(); $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max'); $model = \core_analytics\model::create($statictarget, $indicators, '\\core\\analytics\\time_splitting\\quarters'); $this->assertFalse($model->can_export_configuration()); } /** * Test export_config */ public function test_export_config(): void { if (!self::is_mlbackend_python_configured()) { $this->markTestSkipped('mlbackend_python is not configured.'); } $this->model->enable('\\core\\analytics\\time_splitting\\quarters'); $modelconfig = new \core_analytics\model_config($this->model); $method = new \ReflectionMethod('\\core_analytics\\model_config', 'export_model_data'); $modeldata = $method->invoke($modelconfig); $this->assertArrayHasKey('core', $modeldata->dependencies); $this->assertIsFloat($modeldata->dependencies['core']); $this->assertNotEmpty($modeldata->target); $this->assertNotEmpty($modeldata->timesplitting); $this->assertCount(3, $modeldata->indicators); $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max'); $this->model->update(true, $indicators, false); $modeldata = $method->invoke($modelconfig); $this->assertCount(1, $modeldata->indicators); } /** * Test the implementation of {@link \core_analytics\model::inplace_editable_name()}. */ public function test_inplace_editable_name(): void { global $PAGE; $output = new \core_renderer($PAGE, RENDERER_TARGET_GENERAL); // Check as a user with permission to edit the name. $this->setAdminUser(); $ie = $this->model->inplace_editable_name(); $this->assertInstanceOf(\core\output\inplace_editable::class, $ie); $data = $ie->export_for_template($output); $this->assertEquals('core_analytics', $data['component']); $this->assertEquals('modelname', $data['itemtype']); // Check as a user without permission to edit the name. $this->setGuestUser(); $ie = $this->model->inplace_editable_name(); $this->assertInstanceOf(\core\output\inplace_editable::class, $ie); $data = $ie->export_for_template($output); $this->assertArrayHasKey('displayvalue', $data); } /** * Test how the models present themselves in the UI and that they can be renamed. */ public function test_get_name_and_rename(): void { global $PAGE; $output = new \core_renderer($PAGE, RENDERER_TARGET_GENERAL); // By default, the model exported for template uses its target's name in the name inplace editable element. $this->assertEquals($this->model->get_name(), $this->model->get_target()->get_name()); $data = $this->model->export($output); $this->assertEquals($data->name['displayvalue'], $this->model->get_target()->get_name()); $this->assertEquals($data->name['value'], ''); // Rename the model. $this->model->rename('Nějaký pokusný model'); $this->assertEquals($this->model->get_name(), 'Nějaký pokusný model'); $data = $this->model->export($output); $this->assertEquals($data->name['displayvalue'], 'Nějaký pokusný model'); $this->assertEquals($data->name['value'], 'Nějaký pokusný model'); // Undo the renaming. $this->model->rename(''); $this->assertEquals($this->model->get_name(), $this->model->get_target()->get_name()); $data = $this->model->export($output); $this->assertEquals($data->name['displayvalue'], $this->model->get_target()->get_name()); $this->assertEquals($data->name['value'], ''); } /** * Tests model::get_potential_timesplittings() */ public function test_potential_timesplittings(): void { $this->assertArrayNotHasKey('\core\analytics\time_splitting\no_splitting', $this->model->get_potential_timesplittings()); $this->assertArrayHasKey('\core\analytics\time_splitting\single_range', $this->model->get_potential_timesplittings()); $this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $this->model->get_potential_timesplittings()); } /** * Tests model::get_samples() * * @return null */ public function test_get_samples(): void { if (!PHPUNIT_LONGTEST) { $this->markTestSkipped('PHPUNIT_LONGTEST is not defined'); } // 10000 should be enough to make mssql fail, if we want pgsql to fail we need around 70000 // users, that is a few minutes just to create the users. $nusers = 10000; $userids = []; for ($i = 0; $i < $nusers; $i++) { $user = $this->getDataGenerator()->create_user(); $userids[] = $user->id; } $upcomingactivities = null; foreach (\core_analytics\manager::get_all_models() as $model) { if (get_class($model->get_target()) === 'core_user\\analytics\\target\\upcoming_activities_due') { $upcomingactivities = $model; } } list($sampleids, $samplesdata) = $upcomingactivities->get_samples($userids); $this->assertCount($nusers, $sampleids); $this->assertCount($nusers, $samplesdata); $subset = array_slice($userids, 0, 100); list($sampleids, $samplesdata) = $upcomingactivities->get_samples($subset); $this->assertCount(100, $sampleids); $this->assertCount(100, $samplesdata); $subset = array_slice($userids, 0, 2); list($sampleids, $samplesdata) = $upcomingactivities->get_samples($subset); $this->assertCount(2, $sampleids); $this->assertCount(2, $samplesdata); $subset = array_slice($userids, 0, 1); list($sampleids, $samplesdata) = $upcomingactivities->get_samples($subset); $this->assertCount(1, $sampleids); $this->assertCount(1, $samplesdata); // Unexisting, so nothing returned, but still 2 arrays. list($sampleids, $samplesdata) = $upcomingactivities->get_samples([1231231231231231]); $this->assertEmpty($sampleids); $this->assertEmpty($samplesdata); } /** * Generates a model log record. */ private function add_fake_log() { global $DB, $USER; $log = new \stdClass(); $log->modelid = $this->modelobj->id; $log->version = $this->modelobj->version; $log->target = $this->modelobj->target; $log->indicators = $this->modelobj->indicators; $log->score = 1; $log->info = json_encode([]); $log->dir = 'not important'; $log->timecreated = time(); $log->usermodified = $USER->id; $DB->insert_record('analytics_models_log', $log); } } /** * Testable version to change methods' visibility. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class testable_model extends \core_analytics\model { /** * init_analyser * * @param array $options * @return void */ public function init_analyser($options = array()) { parent::init_analyser($options); } } tests/prediction_actions_test.php000064400000025111152266351020013340 0ustar00. namespace core_analytics; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/fixtures/test_indicator_max.php'); require_once(__DIR__ . '/fixtures/test_target_shortname.php'); /** * Unit tests for prediction actions. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class prediction_actions_test extends \advanced_testcase { /** @var model Store Model. */ protected $model; /** @var \stdClass Store model object. */ protected $modelobj; /** @var \stdClass Course 1 record. */ protected $course1; /** @var \stdClass Course 2 record. */ protected $course2; /** @var \context_course Store Model. */ protected $context; /** @var \stdClass Teacher 1 user record. */ protected $teacher1; /** @var \stdClass Teacher 2 user record. */ protected $teacher2; /** @var \stdClass Teacher 3 user record. */ protected $teacher3; /** * Common startup tasks */ public function setUp(): void { global $DB; parent::setUp(); $this->setAdminUser(); $target = \core_analytics\manager::get_target('test_target_shortname'); $indicators = array('test_indicator_max'); foreach ($indicators as $key => $indicator) { $indicators[$key] = \core_analytics\manager::get_indicator($indicator); } $this->model = \core_analytics\model::create($target, $indicators); $this->modelobj = $this->model->get_model_obj(); $this->model->enable('\core\analytics\time_splitting\single_range'); $this->resetAfterTest(true); $this->course1 = $this->getDataGenerator()->create_course(); $this->course2 = $this->getDataGenerator()->create_course(); $this->context = \context_course::instance($this->course1->id); $this->teacher1 = $this->getDataGenerator()->create_user(); $this->teacher2 = $this->getDataGenerator()->create_user(); $this->teacher3 = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($this->teacher1->id, $this->course1->id, 'editingteacher'); $this->getDataGenerator()->enrol_user($this->teacher2->id, $this->course1->id, 'editingteacher'); $this->getDataGenerator()->enrol_user($this->teacher3->id, $this->course1->id, 'editingteacher'); // The only relevant fields are modelid, contextid and sampleid. I'm cheating and setting // contextid as the course context so teachers can access these predictions. $pred = new \stdClass(); $pred->modelid = $this->model->get_id(); $pred->contextid = $this->context->id; $pred->sampleid = $this->course1->id; $pred->rangeindex = 1; $pred->prediction = 1; $pred->predictionscore = 1; $pred->calculations = json_encode(array('test_indicator_max' => 1)); $pred->timecreated = time(); $DB->insert_record('analytics_predictions', $pred); $pred->sampleid = $this->course2->id; $DB->insert_record('analytics_predictions', $pred); } /** * test_get_predictions */ public function test_action_executed(): void { global $DB; $this->assertEquals(0, $DB->count_records('analytics_prediction_actions')); // Teacher 2 flags a prediction (it doesn't matter which one) as fixed. $this->setUser($this->teacher2); list($ignored, $predictions) = $this->model->get_predictions($this->context, true); $prediction = reset($predictions); $prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target()); $recordset = $this->model->get_prediction_actions($this->context); $this->assertCount(1, $recordset); $recordset->close(); $this->assertEquals(1, $DB->count_records('analytics_prediction_actions')); $action = $DB->get_record('analytics_prediction_actions', array('userid' => $this->teacher2->id)); $this->assertEquals(\core_analytics\prediction::ACTION_FIXED, $action->actionname); $prediction->action_executed(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $this->model->get_target()); $recordset = $this->model->get_prediction_actions($this->context); $this->assertCount(2, $recordset); $recordset->close(); $this->assertEquals(2, $DB->count_records('analytics_prediction_actions')); } /** * Data provider for test_get_executed_actions. * * @return array */ public static function execute_actions_provider(): array { return [ 'Empty actions with no filter' => [ [], [], 0 ], 'Empty actions with filter' => [ [], [\core_analytics\prediction::ACTION_FIXED], 0 ], 'Multiple actions with no filter' => [ [ \core_analytics\prediction::ACTION_FIXED, \core_analytics\prediction::ACTION_FIXED, \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED ], [], 3 ], 'Multiple actions applying filter' => [ [ \core_analytics\prediction::ACTION_FIXED, \core_analytics\prediction::ACTION_FIXED, \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED ], [\core_analytics\prediction::ACTION_FIXED], 2 ], 'Multiple actions not applying filter' => [ [ \core_analytics\prediction::ACTION_FIXED, \core_analytics\prediction::ACTION_FIXED, \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED ], [\core_analytics\prediction::ACTION_NOT_APPLICABLE], 0 ], 'Multiple actions with multiple filter' => [ [ \core_analytics\prediction::ACTION_FIXED, \core_analytics\prediction::ACTION_FIXED, \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED ], [\core_analytics\prediction::ACTION_FIXED, \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED], 3 ], ]; } /** * Tests for get_executed_actions() function. * * @dataProvider execute_actions_provider * @param array $actionstoexecute An array of actions to execute * @param array $actionnamefilter Actions to filter * @param int $returned Number of actions returned * * @covers \core_analytics\prediction::get_executed_actions */ public function test_get_executed_actions(array $actionstoexecute, array $actionnamefilter, int $returned): void { $this->setUser($this->teacher2); list($ignored, $predictions) = $this->model->get_predictions($this->context, true); $prediction = reset($predictions); $target = $this->model->get_target(); foreach($actionstoexecute as $action) { $prediction->action_executed($action, $target); } $filteredactions = $prediction->get_executed_actions($actionnamefilter); $this->assertCount($returned, $filteredactions); } /** * test_get_predictions */ public function test_get_predictions(): void { global $DB; // Already logged in as admin. list($ignored, $predictions) = $this->model->get_predictions($this->context, true); $this->assertCount(2, $predictions); $this->setUser($this->teacher1); list($ignored, $predictions) = $this->model->get_predictions($this->context, true); $this->assertCount(2, $predictions); $this->setUser($this->teacher2); list($ignored, $predictions) = $this->model->get_predictions($this->context, false); $this->assertCount(2, $predictions); // Teacher 2 flags a prediction (it doesn't matter which one). $prediction = reset($predictions); $prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target()); $prediction->action_executed(\core_analytics\prediction::ACTION_NOT_APPLICABLE, $this->model->get_target()); $prediction->action_executed(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $this->model->get_target()); $recordset = $this->model->get_prediction_actions($this->context); $this->assertCount(3, $recordset); $recordset->close(); list($ignored, $predictions) = $this->model->get_predictions($this->context, true); $this->assertCount(1, $predictions); list($ignored, $predictions) = $this->model->get_predictions($this->context, false); $this->assertCount(2, $predictions); // Teacher 1 can still see both predictions. $this->setUser($this->teacher1); list($ignored, $predictions) = $this->model->get_predictions($this->context, true); $this->assertCount(2, $predictions); list($ignored, $predictions) = $this->model->get_predictions($this->context, false); $this->assertCount(2, $predictions); $recordset = $this->model->get_prediction_actions($this->context); $this->assertCount(3, $recordset); $recordset->close(); // Trying with a deleted course. $DB->delete_records('course', ['id' => $this->course2->id]); $this->setUser($this->teacher3); list($ignored, $predictions) = $this->model->get_predictions($this->context); $this->assertCount(1, $predictions); reset($predictions)->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target()); $this->assertEmpty($this->model->get_predictions($this->context)); } } tests/prediction_test.php000064400000117357152266351020011636 0ustar00. namespace core_analytics; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/fixtures/test_indicator_max.php'); require_once(__DIR__ . '/fixtures/test_indicator_min.php'); require_once(__DIR__ . '/fixtures/test_indicator_null.php'); require_once(__DIR__ . '/fixtures/test_indicator_fullname.php'); require_once(__DIR__ . '/fixtures/test_indicator_random.php'); require_once(__DIR__ . '/fixtures/test_indicator_multiclass.php'); require_once(__DIR__ . '/fixtures/test_target_shortname.php'); require_once(__DIR__ . '/fixtures/test_target_shortname_multiclass.php'); require_once(__DIR__ . '/fixtures/test_static_target_shortname.php'); require_once(__DIR__ . '/../../course/lib.php'); use core_analytics\tests\mlbackend_helper_trait; /** * Unit tests for evaluation, training and prediction. * * NOTE: in order to execute this test using a separate server for the * python ML backend you need to define these variables in your config.php file: * * define('TEST_MLBACKEND_PYTHON_HOST', '127.0.0.1'); * define('TEST_MLBACKEND_PYTHON_PORT', 5000); * define('TEST_MLBACKEND_PYTHON_USERNAME', 'default'); * define('TEST_MLBACKEND_PYTHON_PASSWORD', 'sshhhh'); * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class prediction_test extends \advanced_testcase { use mlbackend_helper_trait; /** * Purge all the mlbackend outputs. * * This is done automatically for mlbackends using the web server dataroot but * other mlbackends may store files elsewhere and these files need to be removed. * * @return null */ public function tearDown(): void { $this->setAdminUser(); $models = \core_analytics\manager::get_all_models(); foreach ($models as $model) { $model->delete(); } parent::tearDown(); } /** * test_static_prediction * * @return void */ public function test_static_prediction(): void { global $DB; $this->resetAfterTest(true); $this->setAdminuser(); $model = $this->add_perfect_model('test_static_target_shortname'); $model->enable('\core\analytics\time_splitting\no_splitting'); $this->assertEquals(1, $model->is_enabled()); $this->assertEquals(1, $model->is_trained()); // No training for static models. $results = $model->train(); $trainedsamples = $DB->get_records('analytics_train_samples', array('modelid' => $model->get_id())); $this->assertEmpty($trainedsamples); $this->assertEmpty($DB->count_records('analytics_used_files', array('modelid' => $model->get_id(), 'action' => 'trained'))); // Now we create 2 hidden courses (only hidden courses are getting predictions). $courseparams = array('shortname' => 'aaaaaa', 'fullname' => 'aaaaaa', 'visible' => 0); $course1 = $this->getDataGenerator()->create_course($courseparams); $courseparams = array('shortname' => 'bbbbbb', 'fullname' => 'bbbbbb', 'visible' => 0); $course2 = $this->getDataGenerator()->create_course($courseparams); $result = $model->predict(); // Var $course1 predictions should be 1 == 'a', $course2 predictions should be 0 == 'b'. $correct = array($course1->id => 1, $course2->id => 0); foreach ($result->predictions as $uniquesampleid => $predictiondata) { list($sampleid, $rangeindex) = $model->get_time_splitting()->infer_sample_info($uniquesampleid); // The range index is not important here, both ranges prediction will be the same. $this->assertEquals($correct[$sampleid], $predictiondata->prediction); } // 1 range for each analysable. $predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id())); $this->assertCount(2, $predictedranges); // 2 predictions for each range. $this->assertEquals(2, $DB->count_records('analytics_predictions', array('modelid' => $model->get_id()))); // No new generated records as there are no new courses available. $model->predict(); $predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id())); $this->assertCount(2, $predictedranges); $this->assertEquals(2, $DB->count_records('analytics_predictions', array('modelid' => $model->get_id()))); } /** * test_model_contexts */ public function test_model_contexts(): void { global $DB; $this->resetAfterTest(true); $this->setAdminuser(); $misc = $DB->get_record('course_categories', ['name' => get_string('defaultcategoryname')]); $miscctx = \context_coursecat::instance($misc->id); $category = $this->getDataGenerator()->create_category(); $categoryctx = \context_coursecat::instance($category->id); // One course per category. $courseparams = array('shortname' => 'aaaaaa', 'fullname' => 'aaaaaa', 'visible' => 0, 'category' => $category->id); $course1 = $this->getDataGenerator()->create_course($courseparams); $course1ctx = \context_course::instance($course1->id); $courseparams = array('shortname' => 'bbbbbb', 'fullname' => 'bbbbbb', 'visible' => 0, 'category' => $misc->id); $course2 = $this->getDataGenerator()->create_course($courseparams); $model = $this->add_perfect_model('test_static_target_shortname'); // Just 1 category. $model->update(true, false, '\core\analytics\time_splitting\no_splitting', false, [$categoryctx->id]); $this->assertCount(1, $model->predict()->predictions); // Now with 2 categories. $model->update(true, false, false, false, [$categoryctx->id, $miscctx->id]); // The courses in the new category are processed. $this->assertCount(1, $model->predict()->predictions); // Clear the predictions generated by the model and predict() again. $model->clear(); $this->assertCount(2, $model->predict()->predictions); // Course context restriction. $model->update(true, false, '\core\analytics\time_splitting\no_splitting', false, [$course1ctx->id]); // Nothing new as the course was already analysed. $result = $model->predict(); $this->assertTrue(empty($result->predictions)); $model->clear(); $this->assertCount(1, $model->predict()->predictions); } /** * test_ml_training_and_prediction * * @dataProvider provider_ml_training_and_prediction * @param string $timesplittingid * @param int $predictedrangeindex * @param int $nranges * @param string $predictionsprocessorclass * @param array $forcedconfig * @return void */ public function test_ml_training_and_prediction($timesplittingid, $predictedrangeindex, $nranges, $predictionsprocessorclass, $forcedconfig): void { global $DB; $this->resetAfterTest(true); $this->set_forced_config($forcedconfig); $predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass); $this->setAdminuser(); set_config('enabled_stores', 'logstore_standard', 'tool_log'); // Generate training data. $ncourses = 10; $this->generate_courses($ncourses); $model = $this->add_perfect_model(); $model->update(true, false, $timesplittingid, get_class($predictionsprocessor)); // No samples trained yet. $this->assertEquals(0, $DB->count_records('analytics_train_samples', array('modelid' => $model->get_id()))); $results = $model->train(); $this->assertEquals(1, $model->is_enabled()); $this->assertEquals(1, $model->is_trained()); // 20 courses * the 3 model indicators * the number of time ranges of this time splitting method. $indicatorcalc = 20 * 3 * $nranges; $this->assertEquals($indicatorcalc, $DB->count_records('analytics_indicator_calc')); // 1 training file was created. $trainedsamples = $DB->get_records('analytics_train_samples', array('modelid' => $model->get_id())); $this->assertCount(1, $trainedsamples); $samples = json_decode(reset($trainedsamples)->sampleids, true); $this->assertCount($ncourses * 2, $samples); $this->assertEquals(1, $DB->count_records('analytics_used_files', array('modelid' => $model->get_id(), 'action' => 'trained'))); // Check that analysable files for training are stored under labelled filearea. $fs = get_file_storage(); $this->assertCount(1, $fs->get_directory_files(\context_system::instance()->id, 'analytics', \core_analytics\dataset_manager::LABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false)); $this->assertEmpty($fs->get_directory_files(\context_system::instance()->id, 'analytics', \core_analytics\dataset_manager::UNLABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false)); $params = [ 'startdate' => mktime(0, 0, 0, 10, 24, 2015), 'enddate' => mktime(0, 0, 0, 2, 24, 2016), ]; $courseparams = $params + array('shortname' => 'aaaaaa', 'fullname' => 'aaaaaa', 'visible' => 0); $course1 = $this->getDataGenerator()->create_course($courseparams); $courseparams = $params + array('shortname' => 'bbbbbb', 'fullname' => 'bbbbbb', 'visible' => 0); $course2 = $this->getDataGenerator()->create_course($courseparams); // They will not be skipped for prediction though. $result = $model->predict(); // Var $course1 predictions should be 1 == 'a', $course2 predictions should be 0 == 'b'. $correct = array($course1->id => 1, $course2->id => 0); foreach ($result->predictions as $uniquesampleid => $predictiondata) { list($sampleid, $rangeindex) = $model->get_time_splitting()->infer_sample_info($uniquesampleid); // The range index is not important here, both ranges prediction will be the same. $this->assertEquals($correct[$sampleid], $predictiondata->prediction); } // 1 range will be predicted. $predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id())); $this->assertCount(1, $predictedranges); foreach ($predictedranges as $predictedrange) { $this->assertEquals($predictedrangeindex, $predictedrange->rangeindex); $sampleids = json_decode($predictedrange->sampleids, true); $this->assertCount(2, $sampleids); $this->assertContainsEquals($course1->id, $sampleids); $this->assertContainsEquals($course2->id, $sampleids); } $this->assertEquals(1, $DB->count_records('analytics_used_files', array('modelid' => $model->get_id(), 'action' => 'predicted'))); // 2 predictions. $this->assertEquals(2, $DB->count_records('analytics_predictions', array('modelid' => $model->get_id()))); // Check that analysable files to get predictions are stored under unlabelled filearea. $this->assertCount(1, $fs->get_directory_files(\context_system::instance()->id, 'analytics', \core_analytics\dataset_manager::LABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false)); $this->assertCount(1, $fs->get_directory_files(\context_system::instance()->id, 'analytics', \core_analytics\dataset_manager::UNLABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false)); // No new generated files nor records as there are no new courses available. $model->predict(); $predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id())); $this->assertCount(1, $predictedranges); foreach ($predictedranges as $predictedrange) { $this->assertEquals($predictedrangeindex, $predictedrange->rangeindex); } $this->assertEquals(1, $DB->count_records('analytics_used_files', array('modelid' => $model->get_id(), 'action' => 'predicted'))); $this->assertEquals(2, $DB->count_records('analytics_predictions', array('modelid' => $model->get_id()))); // New samples that can be used for prediction. $courseparams = $params + array('shortname' => 'cccccc', 'fullname' => 'cccccc', 'visible' => 0); $course3 = $this->getDataGenerator()->create_course($courseparams); $courseparams = $params + array('shortname' => 'dddddd', 'fullname' => 'dddddd', 'visible' => 0); $course4 = $this->getDataGenerator()->create_course($courseparams); $result = $model->predict(); $predictedranges = $DB->get_records('analytics_predict_samples', array('modelid' => $model->get_id())); $this->assertCount(1, $predictedranges); foreach ($predictedranges as $predictedrange) { $this->assertEquals($predictedrangeindex, $predictedrange->rangeindex); $sampleids = json_decode($predictedrange->sampleids, true); $this->assertCount(4, $sampleids); $this->assertContainsEquals($course1->id, $sampleids); $this->assertContainsEquals($course2->id, $sampleids); $this->assertContainsEquals($course3->id, $sampleids); $this->assertContainsEquals($course4->id, $sampleids); } $this->assertEquals(2, $DB->count_records('analytics_used_files', array('modelid' => $model->get_id(), 'action' => 'predicted'))); $this->assertEquals(4, $DB->count_records('analytics_predictions', array('modelid' => $model->get_id()))); $this->assertCount(1, $fs->get_directory_files(\context_system::instance()->id, 'analytics', \core_analytics\dataset_manager::LABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false)); $this->assertCount(2, $fs->get_directory_files(\context_system::instance()->id, 'analytics', \core_analytics\dataset_manager::UNLABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false)); // New visible course (for training). $course5 = $this->getDataGenerator()->create_course(array('shortname' => 'aaa', 'fullname' => 'aa')); $course6 = $this->getDataGenerator()->create_course(); $result = $model->train(); $this->assertEquals(2, $DB->count_records('analytics_used_files', array('modelid' => $model->get_id(), 'action' => 'trained'))); $this->assertCount(2, $fs->get_directory_files(\context_system::instance()->id, 'analytics', \core_analytics\dataset_manager::LABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false)); $this->assertCount(2, $fs->get_directory_files(\context_system::instance()->id, 'analytics', \core_analytics\dataset_manager::UNLABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false)); // Confirm that the files associated to the model are deleted on clear and on delete. The ML backend deletion // processes will be triggered by these actions and any exception there would result in a failed test. $model->clear(); $this->assertEquals(0, $DB->count_records('analytics_used_files', array('modelid' => $model->get_id(), 'action' => 'trained'))); $this->assertCount(0, $fs->get_directory_files(\context_system::instance()->id, 'analytics', \core_analytics\dataset_manager::LABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false)); $this->assertCount(0, $fs->get_directory_files(\context_system::instance()->id, 'analytics', \core_analytics\dataset_manager::UNLABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false)); $model->delete(); set_config('enabled_stores', '', 'tool_log'); get_log_manager(true); } /** * provider_ml_training_and_prediction * * @return array */ public static function provider_ml_training_and_prediction(): array { $cases = array( 'no_splitting' => array('\core\analytics\time_splitting\no_splitting', 0, 1), 'quarters' => array('\core\analytics\time_splitting\quarters', 3, 4) ); // We need to test all system prediction processors. return static::add_prediction_processors($cases); } /** * test_ml_export_import * * @param string $predictionsprocessorclass The class name * @param array $forcedconfig * @dataProvider provider_ml_processors */ public function test_ml_export_import($predictionsprocessorclass, $forcedconfig): void { $this->resetAfterTest(true); $this->set_forced_config($forcedconfig); $predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass); $this->setAdminuser(); set_config('enabled_stores', 'logstore_standard', 'tool_log'); // Generate training data. $ncourses = 10; $this->generate_courses($ncourses); $model = $this->add_perfect_model(); $model->update(true, false, '\core\analytics\time_splitting\quarters', get_class($predictionsprocessor)); $model->train(); $this->assertTrue($model->trained_locally()); $this->generate_courses(10, ['visible' => 0]); $originalresults = $model->predict(); $zipfilename = 'model-zip-' . microtime() . '.zip'; $zipfilepath = $model->export_model($zipfilename); $modelconfig = new \core_analytics\model_config(); list($modelconfig, $mlbackend) = $modelconfig->extract_import_contents($zipfilepath); $this->assertNotFalse($mlbackend); $importmodel = \core_analytics\model::import_model($zipfilepath); $importmodel->enable(); // Now predict using the imported model without prior training. $importedmodelresults = $importmodel->predict(); foreach ($originalresults->predictions as $sampleid => $prediction) { $this->assertEquals($importedmodelresults->predictions[$sampleid]->prediction, $prediction->prediction); } $this->assertFalse($importmodel->trained_locally()); $zipfilename = 'model-zip-' . microtime() . '.zip'; $zipfilepath = $model->export_model($zipfilename, false); $modelconfig = new \core_analytics\model_config(); list($modelconfig, $mlbackend) = $modelconfig->extract_import_contents($zipfilepath); $this->assertFalse($mlbackend); set_config('enabled_stores', '', 'tool_log'); get_log_manager(true); } /** * provider_ml_processors * * @return array */ public static function provider_ml_processors(): array { $cases = [ 'case' => [], ]; // We need to test all system prediction processors. return static::add_prediction_processors($cases); } /** * Test the system classifiers returns. * * This test checks that all mlbackend plugins in the system are able to return proper status codes * even under weird situations. * * @dataProvider provider_ml_classifiers_return * @param int $success * @param int $nsamples * @param int $classes * @param string $predictionsprocessorclass * @param array $forcedconfig * @return void */ public function test_ml_classifiers_return($success, $nsamples, $classes, $predictionsprocessorclass, $forcedconfig): void { $this->resetAfterTest(); $this->set_forced_config($forcedconfig); $predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass); if ($nsamples % count($classes) != 0) { throw new \coding_exception('The number of samples should be divisible by the number of classes'); } $samplesperclass = $nsamples / count($classes); // Metadata (we pass 2 classes even if $classes only provides 1 class samples as we want to test // what the backend does in this case. $dataset = "nfeatures,targetclasses,targettype" . PHP_EOL; $dataset .= "3,\"[0,1]\",\"discrete\"" . PHP_EOL; // Headers. $dataset .= "feature1,feature2,feature3,target" . PHP_EOL; foreach ($classes as $class) { for ($i = 0; $i < $samplesperclass; $i++) { $dataset .= "1,0,1,$class" . PHP_EOL; } } $trainingfile = array( 'contextid' => \context_system::instance()->id, 'component' => 'analytics', 'filearea' => 'labelled', 'itemid' => 123, 'filepath' => '/', 'filename' => 'whocares.csv' ); $fs = get_file_storage(); $dataset = $fs->create_file_from_string($trainingfile, $dataset); // Training should work correctly if at least 1 sample of each class is included. $dir = make_request_directory(); $modeluniqueid = 'whatever' . microtime(); $result = $predictionsprocessor->train_classification($modeluniqueid, $dataset, $dir); switch ($success) { case 'yes': $this->assertEquals(\core_analytics\model::OK, $result->status); break; case 'no': $this->assertNotEquals(\core_analytics\model::OK, $result->status); break; case 'maybe': default: // We just check that an object is returned so we don't have an empty check, // what we really want to check is that an exception was not thrown. $this->assertInstanceOf(\stdClass::class, $result); } // Purge the directory used in this test (useful in case the mlbackend is storing files // somewhere out of the default moodledata/models dir. $predictionsprocessor->delete_output_dir($dir, $modeluniqueid); } /** * test_ml_classifiers_return provider * * We can not be very specific here as test_ml_classifiers_return only checks that * mlbackend plugins behave and expected and control properly backend errors even * under weird situations. * * @return array */ public static function provider_ml_classifiers_return(): array { // Using verbose options as the first argument for readability. $cases = array( '1-samples' => array('maybe', 1, [0]), '2-samples-same-class' => array('maybe', 2, [0]), '2-samples-different-classes' => array('yes', 2, [0, 1]), '4-samples-different-classes' => array('yes', 4, [0, 1]) ); // We need to test all system prediction processors. return static::add_prediction_processors($cases); } /** * Tests correct multi-classification. * * @dataProvider provider_test_multi_classifier * @param string $timesplittingid * @param string $predictionsprocessorclass * @param array|null $forcedconfig * @throws coding_exception * @throws moodle_exception */ public function test_ml_multi_classifier($timesplittingid, $predictionsprocessorclass, $forcedconfig): void { global $DB; $this->resetAfterTest(true); $this->setAdminuser(); set_config('enabled_stores', 'logstore_standard', 'tool_log'); $this->set_forced_config($forcedconfig); $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false); if ($predictionsprocessor->is_ready() !== true) { $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.'); } // Generate training courses. $ncourses = 5; $this->generate_courses(ncourses: $ncourses, ismulticlass: true); $model = $this->add_multiclass_model(); $model->update(true, false, $timesplittingid, get_class($predictionsprocessor)); $results = $model->train(); $params = [ 'startdate' => mktime(0, 0, 0, 10, 24, 2015), 'enddate' => mktime(0, 0, 0, 2, 24, 2016), ]; $courseparams = $params + array('shortname' => 'aaaaaa', 'fullname' => 'aaaaaa', 'visible' => 0); $course1 = $this->getDataGenerator()->create_course($courseparams); $courseparams = $params + array('shortname' => 'bbbbbb', 'fullname' => 'bbbbbb', 'visible' => 0); $course2 = $this->getDataGenerator()->create_course($courseparams); $courseparams = $params + array('shortname' => 'cccccc', 'fullname' => 'cccccc', 'visible' => 0); $course3 = $this->getDataGenerator()->create_course($courseparams); // They will not be skipped for prediction though. $result = $model->predict(); // The $course1 predictions should be 0 == 'a', $course2 should be 1 == 'b' and $course3 should be 2 == 'c'. $correct = array($course1->id => 0, $course2->id => 1, $course3->id => 2); foreach ($result->predictions as $uniquesampleid => $predictiondata) { list($sampleid, $rangeindex) = $model->get_time_splitting()->infer_sample_info($uniquesampleid); // The range index is not important here, both ranges prediction will be the same. $this->assertEquals($correct[$sampleid], $predictiondata->prediction); } set_config('enabled_stores', '', 'tool_log'); get_log_manager(true); } /** * Provider for the multi_classification test. * * @return array */ public static function provider_test_multi_classifier(): array { $cases = array( 'notimesplitting' => array('\core\analytics\time_splitting\no_splitting'), ); // Add all system prediction processors. return static::add_prediction_processors($cases); } /** * Basic test to check that prediction processors work as expected. * * @coversNothing * @dataProvider provider_ml_test_evaluation_configuration * @param string $modelquality * @param int $ncourses * @param array $expected * @param string $predictionsprocessorclass * @param array $forcedconfig * @return void */ public function test_ml_evaluation_configuration($modelquality, $ncourses, $expected, $predictionsprocessorclass, $forcedconfig): void { $this->resetAfterTest(true); $this->set_forced_config($forcedconfig); $predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass); $this->setAdminuser(); set_config('enabled_stores', 'logstore_standard', 'tool_log'); $sometimesplittings = '\core\analytics\time_splitting\single_range,' . '\core\analytics\time_splitting\quarters'; set_config('defaulttimesplittingsevaluation', $sometimesplittings, 'analytics'); if ($modelquality === 'perfect') { $model = $this->add_perfect_model(); } else if ($modelquality === 'random') { $model = $this->add_random_model(); } else { throw new \coding_exception('Only perfect and random accepted as $modelquality values'); } // Generate training data. $this->generate_courses($ncourses); $model->update(false, false, false, get_class($predictionsprocessor)); $results = $model->evaluate(); // We check that the returned status includes at least $expectedcode code. foreach ($results as $timesplitting => $result) { $message = 'The returned status code ' . $result->status . ' should include ' . $expected[$timesplitting]; $filtered = $result->status & $expected[$timesplitting]; $this->assertEquals($expected[$timesplitting], $filtered, $message); $options = ['evaluation' => true, 'reuseprevanalysed' => true]; $result = new \core_analytics\local\analysis\result_file($model->get_id(), true, $options); $timesplittingobj = \core_analytics\manager::get_time_splitting($timesplitting); $analysable = new \core_analytics\site(); $cachedanalysis = $result->retrieve_cached_result($timesplittingobj, $analysable); $this->assertInstanceOf(\stored_file::class, $cachedanalysis); } set_config('enabled_stores', '', 'tool_log'); get_log_manager(true); } /** * Tests the evaluation of already trained models. * * @coversNothing * @dataProvider provider_ml_processors * @param string $predictionsprocessorclass * @param array $forcedconfig * @return null */ public function test_ml_evaluation_trained_model($predictionsprocessorclass, $forcedconfig): void { $this->resetAfterTest(true); $this->set_forced_config($forcedconfig); $predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass); $this->setAdminuser(); set_config('enabled_stores', 'logstore_standard', 'tool_log'); $model = $this->add_perfect_model(); // Generate training data. $this->generate_courses(50); $model->update(true, false, '\\core\\analytics\\time_splitting\\quarters', get_class($predictionsprocessor)); $model->train(); $zipfilename = 'model-zip-' . microtime() . '.zip'; $zipfilepath = $model->export_model($zipfilename); $importmodel = \core_analytics\model::import_model($zipfilepath); $results = $importmodel->evaluate(['mode' => 'trainedmodel']); $this->assertEquals(0, $results['\\core\\analytics\\time_splitting\\quarters']->status); $this->assertEquals(1, $results['\\core\\analytics\\time_splitting\\quarters']->score); set_config('enabled_stores', '', 'tool_log'); get_log_manager(true); } /** * test_read_indicator_calculations * * @return void */ public function test_read_indicator_calculations(): void { global $DB; $this->resetAfterTest(true); $starttime = 123; $endtime = 321; $sampleorigin = 'whatever'; $indicator = $this->getMockBuilder('test_indicator_max')->onlyMethods(['calculate_sample'])->getMock(); $indicator->expects($this->never())->method('calculate_sample'); $existingcalcs = array(111 => 1, 222 => -1); $sampleids = array(111 => 111, 222 => 222); list($values, $unused) = $indicator->calculate($sampleids, $sampleorigin, $starttime, $endtime, $existingcalcs); } /** * test_not_null_samples */ public function test_not_null_samples(): void { $this->resetAfterTest(true); $timesplitting = \core_analytics\manager::get_time_splitting('\core\analytics\time_splitting\quarters'); $timesplitting->set_analysable(new \core_analytics\site()); $ranges = array( array('start' => 111, 'end' => 222, 'time' => 222), array('start' => 222, 'end' => 333, 'time' => 333) ); $samples = array(123 => 123, 321 => 321); $target = \core_analytics\manager::get_target('test_target_shortname'); $indicators = array('test_indicator_null', 'test_indicator_min'); foreach ($indicators as $key => $indicator) { $indicators[$key] = \core_analytics\manager::get_indicator($indicator); } $model = \core_analytics\model::create($target, $indicators, '\core\analytics\time_splitting\no_splitting'); $analyser = $model->get_analyser(); $result = new \core_analytics\local\analysis\result_array($model->get_id(), false, $analyser->get_options()); $analysis = new \core_analytics\analysis($analyser, false, $result); // Samples with at least 1 not null value are returned. $params = array( $timesplitting, $samples, $ranges ); $dataset = \phpunit_util::call_internal_method($analysis, 'calculate_indicators', $params, '\core_analytics\analysis'); $this->assertArrayHasKey('123-0', $dataset); $this->assertArrayHasKey('123-1', $dataset); $this->assertArrayHasKey('321-0', $dataset); $this->assertArrayHasKey('321-1', $dataset); $indicators = array('test_indicator_null'); foreach ($indicators as $key => $indicator) { $indicators[$key] = \core_analytics\manager::get_indicator($indicator); } $model = \core_analytics\model::create($target, $indicators, '\core\analytics\time_splitting\no_splitting'); $analyser = $model->get_analyser(); $result = new \core_analytics\local\analysis\result_array($model->get_id(), false, $analyser->get_options()); $analysis = new \core_analytics\analysis($analyser, false, $result); // Samples with only null values are not returned. $params = array( $timesplitting, $samples, $ranges ); $dataset = \phpunit_util::call_internal_method($analysis, 'calculate_indicators', $params, '\core_analytics\analysis'); $this->assertArrayNotHasKey('123-0', $dataset); $this->assertArrayNotHasKey('123-1', $dataset); $this->assertArrayNotHasKey('321-0', $dataset); $this->assertArrayNotHasKey('321-1', $dataset); } /** * provider_ml_test_evaluation_configuration * * @return array */ public static function provider_ml_test_evaluation_configuration(): array { $cases = array( 'bad' => array( 'modelquality' => 'random', 'ncourses' => 50, 'expected' => array( '\core\analytics\time_splitting\single_range' => \core_analytics\model::LOW_SCORE, '\core\analytics\time_splitting\quarters' => \core_analytics\model::LOW_SCORE, ) ), 'good' => array( 'modelquality' => 'perfect', 'ncourses' => 50, 'expected' => array( '\core\analytics\time_splitting\single_range' => \core_analytics\model::OK, '\core\analytics\time_splitting\quarters' => \core_analytics\model::OK, ) ) ); return static::add_prediction_processors($cases); } /** * add_random_model * * @return \core_analytics\model */ protected function add_random_model() { $target = \core_analytics\manager::get_target('test_target_shortname'); $indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_random'); foreach ($indicators as $key => $indicator) { $indicators[$key] = \core_analytics\manager::get_indicator($indicator); } $model = \core_analytics\model::create($target, $indicators); // To load db defaults as well. return new \core_analytics\model($model->get_id()); } /** * add_perfect_model * * @param string $targetclass * @return \core_analytics\model */ protected function add_perfect_model($targetclass = 'test_target_shortname') { $target = \core_analytics\manager::get_target($targetclass); $indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_fullname'); foreach ($indicators as $key => $indicator) { $indicators[$key] = \core_analytics\manager::get_indicator($indicator); } $model = \core_analytics\model::create($target, $indicators); // To load db defaults as well. return new \core_analytics\model($model->get_id()); } /** * Generates model for multi-classification * * @param string $targetclass * @return \core_analytics\model * @throws coding_exception * @throws moodle_exception */ public function add_multiclass_model($targetclass = 'test_target_shortname_multiclass') { $target = \core_analytics\manager::get_target($targetclass); $indicators = array('test_indicator_fullname', 'test_indicator_multiclass'); foreach ($indicators as $key => $indicator) { $indicators[$key] = \core_analytics\manager::get_indicator($indicator); } $model = \core_analytics\model::create($target, $indicators); return new \core_analytics\model($model->get_id()); } /** * Forces some configuration values. * * @param array $forcedconfig */ protected function set_forced_config($forcedconfig) { \core_analytics\manager::reset_prediction_processors(); if (empty($forcedconfig)) { return; } foreach ($forcedconfig as $pluginname => $pluginconfig) { foreach ($pluginconfig as $name => $value) { set_config($name, $value, $pluginname); } } } /** * Is the provided processor ready using the current configuration in the site? * * @param string $predictionsprocessorclass * @return \core_analytics\predictor */ protected function is_predictions_processor_ready(string $predictionsprocessorclass) { // We repeat the test for all prediction processors. $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false); $ready = $predictionsprocessor->is_ready(); if ($ready !== true) { $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready: ' . $ready); } return $predictionsprocessor; } /** * add_prediction_processors * * @param array $cases * @return array */ protected static function add_prediction_processors($cases): array { $return = array(); if (defined('TEST_MLBACKEND_PYTHON_HOST') && defined('TEST_MLBACKEND_PYTHON_PORT') && defined('TEST_MLBACKEND_PYTHON_USERNAME') && defined('TEST_MLBACKEND_PYTHON_USERNAME')) { $testpythonserver = true; } // We need to test all prediction processors in the system. $predictionprocessors = \core_analytics\manager::get_all_prediction_processors(); foreach ($predictionprocessors as $classfullname => $predictionsprocessor) { foreach ($cases as $key => $case) { if (!$predictionsprocessor instanceof \mlbackend_python\processor || empty($testpythonserver)) { $extraparams = ['predictionsprocessorclass' => $classfullname, 'forcedconfig' => null]; $return[$key . '-' . $classfullname] = $case + $extraparams; } else { // We want the configuration to be forced during the test as things like importing models create new // instances of ML backend processors during the process. $forcedconfig = ['mlbackend_python' => ['useserver' => true, 'host' => TEST_MLBACKEND_PYTHON_HOST, 'port' => TEST_MLBACKEND_PYTHON_PORT, 'secure' => false, 'username' => TEST_MLBACKEND_PYTHON_USERNAME, 'password' => TEST_MLBACKEND_PYTHON_PASSWORD]]; $casekey = $key . '-' . $classfullname . '-server'; $return[$casekey] = $case + ['predictionsprocessorclass' => $classfullname, 'forcedconfig' => $forcedconfig]; } } } return $return; } } tests/course_test.php000064400000014744152266351020010772 0ustar00. namespace core_analytics; /** * Unit tests for course. * * @package core_analytics * @copyright 2016 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class course_test extends \advanced_testcase { /** @var \stdClass Course record. */ protected $course; /** @var \stdClass Student 1 user record. */ protected $stu1; /** @var \stdClass Student 2 user record. */ protected $stu2; /** @var \stdClass Student both user record. */ protected $both; /** @var \stdClass Editing teacher user record. */ protected $editingteacher; /** @var \stdClass Teacher user record. */ protected $teacher; /** @var int Student role ID record. */ protected $studentroleid; /** @var int Editing teacher role ID record. */ protected $editingteacherroleid; /** @var int Teacher role ID record. */ protected $teacherroleid; public function setUp(): void { global $DB; parent::setUp(); $this->course = $this->getDataGenerator()->create_course(['startdate' => 0]); $this->stu1 = $this->getDataGenerator()->create_user(); $this->stu2 = $this->getDataGenerator()->create_user(); $this->both = $this->getDataGenerator()->create_user(); $this->editingteacher = $this->getDataGenerator()->create_user(); $this->teacher = $this->getDataGenerator()->create_user(); $this->studentroleid = $DB->get_field('role', 'id', array('shortname' => 'student')); $this->editingteacherroleid = $DB->get_field('role', 'id', array('shortname' => 'editingteacher')); $this->teacherroleid = $DB->get_field('role', 'id', array('shortname' => 'teacher')); $this->getDataGenerator()->enrol_user($this->stu1->id, $this->course->id, $this->studentroleid); $this->getDataGenerator()->enrol_user($this->stu2->id, $this->course->id, $this->studentroleid); $this->getDataGenerator()->enrol_user($this->both->id, $this->course->id, $this->studentroleid); $this->getDataGenerator()->enrol_user($this->both->id, $this->course->id, $this->editingteacherroleid); $this->getDataGenerator()->enrol_user($this->editingteacher->id, $this->course->id, $this->editingteacherroleid); $this->getDataGenerator()->enrol_user($this->teacher->id, $this->course->id, $this->teacherroleid); } /** * Users tests. */ public function test_users(): void { global $DB; $this->resetAfterTest(true); $courseman = new \core_analytics\course($this->course->id); $this->assertCount(3, $courseman->get_user_ids(array($this->studentroleid))); $this->assertCount(2, $courseman->get_user_ids(array($this->editingteacherroleid))); $this->assertCount(1, $courseman->get_user_ids(array($this->teacherroleid))); // Distinct is applied. $this->assertCount(3, $courseman->get_user_ids(array($this->editingteacherroleid, $this->teacherroleid))); $this->assertCount(4, $courseman->get_user_ids(array($this->editingteacherroleid, $this->studentroleid))); } /** * Course validation tests. * * @return void */ public function test_course_validation(): void { global $DB; $this->resetAfterTest(true); $courseman = new \core_analytics\course($this->course->id); $this->assertFalse($courseman->was_started()); $this->assertFalse($courseman->is_finished()); // Nothing should change when assigning as teacher. for ($i = 0; $i < 10; $i++) { $user = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($user->id, $this->course->id, $this->teacherroleid); } $courseman = new \core_analytics\course($this->course->id); $this->assertFalse($courseman->was_started()); $this->assertFalse($courseman->is_finished()); // More students now. for ($i = 0; $i < 10; $i++) { $user = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($user->id, $this->course->id, $this->studentroleid); } $courseman = new \core_analytics\course($this->course->id); $this->assertFalse($courseman->was_started()); $this->assertFalse($courseman->is_finished()); // Valid start date unknown end date. $this->course->startdate = gmmktime('0', '0', '0', 10, 24, 2015); $DB->update_record('course', $this->course); $courseman = new \core_analytics\course($this->course->id); $this->assertTrue($courseman->was_started()); $this->assertFalse($courseman->is_finished()); // Valid start and end date. $this->course->enddate = gmmktime('0', '0', '0', 8, 27, 2016); $DB->update_record('course', $this->course); $courseman = new \core_analytics\course($this->course->id); $this->assertTrue($courseman->was_started()); $this->assertTrue($courseman->is_finished()); // Valid start and ongoing course. $this->course->enddate = gmmktime('0', '0', '0', 8, 27, 2286); $DB->update_record('course', $this->course); $courseman = new \core_analytics\course($this->course->id); $this->assertTrue($courseman->was_started()); $this->assertFalse($courseman->is_finished()); } /** * Get the minimum time that is considered valid according to guess_end logic. * * @param int $time * @return int */ protected function time_greater_than($time) { return $time - (WEEKSECS * 2); } /** * Get the maximum time that is considered valid according to guess_end logic. * * @param int $time * @return int */ protected function time_less_than($time) { return $time + (WEEKSECS * 2); } } tests/calculation_info_test.php000064400000007612152266351020012777 0ustar00. namespace core_analytics; /** * Unit tests for the calculation info cache. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class calculation_info_test extends \advanced_testcase { /** * test_calculation_info description * * @dataProvider provider_test_calculation_info_add_pull * @param mixed $info1 * @param mixed $info2 * @param mixed $info3 * @param mixed $info4 * @return null */ public function test_calculation_info_add_pull($info1, $info2, $info3, $info4): void { require_once(__DIR__ . '/fixtures/test_indicator_max.php'); require_once(__DIR__ . '/fixtures/test_indicator_min.php'); $this->resetAfterTest(); $atimesplitting = new \core\analytics\time_splitting\quarters(); $indicator1 = new \test_indicator_min(); $indicator2 = new \test_indicator_max(); $calculationinfo = new \core_analytics\calculation_info(); $calculationinfo->add_shared(111, [111 => $info1]); $calculationinfo->add_shared(222, [222 => 'should-get-overwritten-in-next-line']); $calculationinfo->add_shared(222, [222 => $info2]); $calculationinfo->save($indicator1, $atimesplitting, 0); // We also check that the eheheh does not overwrite the value previously stored in the cache // during the previous save call. $calculationinfo->add_shared(222, [222 => 'eheheh']); $calculationinfo->save($indicator1, $atimesplitting, 0); // The method save() should clear the internal attrs in \core_analytics\calculation_info // so it is fine to reuse the same calculation_info instance. $calculationinfo->add_shared(111, [111 => $info3]); $calculationinfo->add_shared(333, [333 => $info4]); $calculationinfo->save($indicator2, $atimesplitting, 0); // We pull data in rangeindex '0' for samples 111, 222 and 333. $predictionrecords = [ '111-0' => (object)['sampleid' => '111'], '222-0' => (object)['sampleid' => '222'], '333-0' => (object)['sampleid' => '333'], ]; $info = \core_analytics\calculation_info::pull_info($predictionrecords); $this->assertCount(3, $info); $this->assertCount(2, $info[111]); $this->assertCount(1, $info[222]); $this->assertCount(1, $info[333]); $this->assertEquals($info1, $info[111]['test_indicator_min:extradata'][111]); $this->assertEquals($info2, $info[222]['test_indicator_min:extradata'][222]); $this->assertEquals($info3, $info[111]['test_indicator_max:extradata'][111]); $this->assertEquals($info4, $info[333]['test_indicator_max:extradata'][333]); // The calculationinfo cache gets emptied. $this->assertFalse(\core_analytics\calculation_info::pull_info($predictionrecords)); } /** * provider_test_calculation_info_add_pull * * @return mixed[] */ public static function provider_test_calculation_info_add_pull(): array { return [ 'mixed-types' => ['asd', true, [123, 123, 123], (object)['asd' => 'fgfg']], ]; } } tests/time_splittings_test.php000064400000036124152266351020012704 0ustar00. namespace core_analytics; use test_timesplitting_seconds; use test_timesplitting_upcoming_seconds; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/fixtures/test_timesplitting_seconds.php'); require_once(__DIR__ . '/fixtures/test_timesplitting_upcoming_seconds.php'); require_once(__DIR__ . '/../../lib/enrollib.php'); /** * Unit tests for core time splitting methods. * * @package core * @category test * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class time_splittings_test extends \advanced_testcase { /** @var \stdClass course record. */ protected $course; /** @var course Moodle course analysable. */ protected $analysable; /** * setUp * * @return void */ public function setUp(): void { parent::setUp(); $this->resetAfterTest(true); // Generate training data. $params = array( 'startdate' => mktime(8, 15, 32, 10, 24, 2015), 'enddate' => mktime(12, 12, 31, 10, 24, 2016), ); $this->course = $this->getDataGenerator()->create_course($params); $this->analysable = new \core_analytics\course($this->course); } /** * test_ranges * * @return void */ public function test_valid_ranges(): void { // All core time splitting methods. $timesplittings = array( '\core\analytics\time_splitting\deciles', '\core\analytics\time_splitting\deciles_accum', '\core\analytics\time_splitting\no_splitting', '\core\analytics\time_splitting\quarters', '\core\analytics\time_splitting\quarters_accum', '\core\analytics\time_splitting\single_range', '\core\analytics\time_splitting\upcoming_week', ); // Check that defined ranges are valid (tested through validate_ranges). foreach ($timesplittings as $timesplitting) { $instance = new $timesplitting(); $instance->set_analysable($this->analysable); } } /** * test_range_dates * * @return void */ public function test_range_dates(): void { $nov2015 = mktime(0, 0, 0, 11, 24, 2015); $aug2016 = mktime(0, 0, 0, 8, 29, 2016); // Equal parts. $quarters = new \core\analytics\time_splitting\quarters(); $quarters->set_analysable($this->analysable); $ranges = $quarters->get_all_ranges(); $this->assertCount(4, $ranges); $this->assertCount(4, $quarters->get_training_ranges()); $this->assertCount(4, $quarters->get_distinct_ranges()); $this->assertGreaterThan($ranges[0]['start'], $ranges[1]['start']); $this->assertGreaterThan($ranges[0]['end'], $ranges[1]['start']); $this->assertGreaterThan($ranges[0]['end'], $ranges[1]['end']); $this->assertGreaterThan($ranges[1]['start'], $ranges[2]['start']); $this->assertGreaterThan($ranges[1]['end'], $ranges[2]['start']); $this->assertGreaterThan($ranges[1]['end'], $ranges[2]['end']); $this->assertGreaterThan($ranges[2]['start'], $ranges[3]['start']); $this->assertGreaterThan($ranges[2]['end'], $ranges[3]['end']); $this->assertGreaterThan($ranges[2]['end'], $ranges[3]['start']); // First range. $this->assertLessThan($nov2015, $ranges[0]['start']); $this->assertGreaterThan($nov2015, $ranges[0]['end']); // Last range. $this->assertLessThan($aug2016, $ranges[3]['start']); $this->assertGreaterThan($aug2016, $ranges[3]['end']); // Accumulative. $accum = new \core\analytics\time_splitting\quarters_accum(); $accum->set_analysable($this->analysable); $ranges = $accum->get_all_ranges(); $this->assertCount(4, $ranges); $this->assertCount(4, $accum->get_training_ranges()); $this->assertCount(4, $accum->get_distinct_ranges()); $this->assertEquals($ranges[0]['start'], $ranges[1]['start']); $this->assertEquals($ranges[1]['start'], $ranges[2]['start']); $this->assertEquals($ranges[2]['start'], $ranges[3]['start']); $this->assertGreaterThan($ranges[0]['end'], $ranges[1]['end']); $this->assertGreaterThan($ranges[1]['end'], $ranges[2]['end']); $this->assertGreaterThan($ranges[2]['end'], $ranges[3]['end']); // Present in all ranges. $this->assertLessThan($nov2015, $ranges[0]['start']); $this->assertGreaterThan($nov2015, $ranges[0]['end']); $this->assertGreaterThan($nov2015, $ranges[1]['end']); $this->assertGreaterThan($nov2015, $ranges[2]['end']); $this->assertGreaterThan($nov2015, $ranges[3]['end']); // Only in the last range. $this->assertLessThan($aug2016, $ranges[0]['end']); $this->assertLessThan($aug2016, $ranges[1]['end']); $this->assertLessThan($aug2016, $ranges[2]['end']); $this->assertLessThan($aug2016, $ranges[3]['start']); $this->assertGreaterThan($aug2016, $ranges[3]['end']); } /** * test_ready_predict * * @return void */ public function test_ready_predict(): void { $quarters = new \core\analytics\time_splitting\quarters(); $nosplitting = new \core\analytics\time_splitting\no_splitting(); $singlerange = new \core\analytics\time_splitting\single_range(); $range = array( 'start' => time() - 100, 'end' => time() - 20, ); $range['time'] = $range['end']; $this->assertTrue($quarters->ready_to_predict($range)); $this->assertTrue($nosplitting->ready_to_predict($range)); // Single range time is 0. $range['time'] = 0; $this->assertTrue($singlerange->ready_to_predict($range)); $range = array( 'start' => time() + 20, 'end' => time() + 100, ); $range['time'] = $range['end']; $this->assertFalse($quarters->ready_to_predict($range)); $this->assertTrue($nosplitting->ready_to_predict($range)); // Single range time is 0. $range['time'] = 0; $this->assertTrue($singlerange->ready_to_predict($range)); } /** * test_periodic * * @return void */ public function test_periodic(): void { // Using a finished course. $pastweek = new \core\analytics\time_splitting\past_week(); $pastweek->set_analysable($this->analysable); $this->assertCount(1, $pastweek->get_distinct_ranges()); $ranges = $pastweek->get_all_ranges(); $this->assertEquals(52, count($ranges)); $this->assertEquals($this->course->startdate, $ranges[0]['start']); $this->assertNotEquals($this->course->startdate, $ranges[0]['time']); // The analysable is finished so all ranges are available for training. $this->assertCount(count($ranges), $pastweek->get_training_ranges()); $ranges = $pastweek->get_most_recent_prediction_range(); $range = reset($ranges); $this->assertEquals(51, key($ranges)); // We now use an ongoing course not yet ready to generate predictions. $threedaysago = new \DateTime('-3 days'); $params = array( 'startdate' => $threedaysago->getTimestamp(), ); $ongoingcourse = $this->getDataGenerator()->create_course($params); $ongoinganalysable = new \core_analytics\course($ongoingcourse); $pastweek = new \core\analytics\time_splitting\past_week(); $pastweek->set_analysable($ongoinganalysable); $ranges = $pastweek->get_all_ranges(); $this->assertEquals(0, count($ranges)); $this->assertCount(0, $pastweek->get_training_ranges()); // We now use a ready-to-predict ongoing course. $onemonthago = new \DateTime('-30 days'); $params = array( 'startdate' => $onemonthago->getTimestamp(), ); $ongoingcourse = $this->getDataGenerator()->create_course($params); $ongoinganalysable = new \core_analytics\course($ongoingcourse); $pastweek = new \core\analytics\time_splitting\past_week(); $pastweek->set_analysable($ongoinganalysable); $this->assertCount(1, $pastweek->get_distinct_ranges()); $ranges = $pastweek->get_all_ranges(); $this->assertEquals(4, count($ranges)); $this->assertCount(4, $pastweek->get_training_ranges()); $ranges = $pastweek->get_most_recent_prediction_range(); $range = reset($ranges); $this->assertEquals(3, key($ranges)); $this->assertEqualsWithDelta(time(), $range['time'], 1); // 1 second delta for the start just in case a second passes between the set_analysable call // and this checking below. $time = new \DateTime(); $time->sub($pastweek->periodicity()); $this->assertEqualsWithDelta($time->getTimestamp(), $range['start'], 1.0); $this->assertEqualsWithDelta(time(), $range['end'], 1); $starttime = time(); $upcomingweek = new \core\analytics\time_splitting\upcoming_week(); $upcomingweek->set_analysable($ongoinganalysable); $this->assertCount(1, $upcomingweek->get_distinct_ranges()); $ranges = $upcomingweek->get_all_ranges(); $this->assertEquals(1, count($ranges)); $range = reset($ranges); $this->assertEqualsWithDelta(time(), $range['time'], 1); $this->assertEqualsWithDelta(time(), $range['start'], 1); $this->assertGreaterThan(time(), $range['end']); $this->assertCount(0, $upcomingweek->get_training_ranges()); $ranges = $upcomingweek->get_most_recent_prediction_range(); $range = reset($ranges); $this->assertEquals(0, key($ranges)); $this->assertEqualsWithDelta(time(), $range['time'], 1); $this->assertEqualsWithDelta(time(), $range['start'], 1); $this->assertGreaterThanOrEqual($starttime, $range['time']); $this->assertGreaterThanOrEqual($starttime, $range['start']); $this->assertGreaterThan(time(), $range['end']); $this->assertNotEmpty($upcomingweek->get_range_by_index(0)); $this->assertFalse($upcomingweek->get_range_by_index(1)); // We now check how new ranges get added as time passes. $fewsecsago = new \DateTime('-5 seconds'); $params = array( 'startdate' => $fewsecsago->getTimestamp(), 'enddate' => (new \DateTimeImmutable('+1 year'))->getTimestamp(), ); $course = $this->getDataGenerator()->create_course($params); $analysable = new \core_analytics\course($course); $seconds = new test_timesplitting_seconds(); $seconds->set_analysable($analysable); // Store the ranges we just obtained. $ranges = $seconds->get_all_ranges(); $nranges = count($ranges); $ntrainingranges = count($seconds->get_training_ranges()); $mostrecentrange = $seconds->get_most_recent_prediction_range(); $mostrecentrange = reset($mostrecentrange); // We wait for the next range to be added. sleep(1); // We set the analysable again so the time ranges are recalculated. $seconds->set_analysable($analysable); $newranges = $seconds->get_all_ranges(); $nnewranges = count($newranges); $nnewtrainingranges = $seconds->get_training_ranges(); $newmostrecentrange = $seconds->get_most_recent_prediction_range(); $newmostrecentrange = reset($newmostrecentrange); $this->assertGreaterThan($nranges, $nnewranges); $this->assertGreaterThan($ntrainingranges, $nnewtrainingranges); $this->assertGreaterThan($mostrecentrange['time'], $newmostrecentrange['time']); // All the ranges but the last one should return the same values. array_pop($ranges); array_pop($newranges); foreach ($ranges as $key => $range) { $this->assertEquals($newranges[$key]['start'], $range['start']); $this->assertEquals($newranges[$key]['end'], $range['end']); $this->assertEquals($newranges[$key]['time'], $range['time']); } // Fake model id, we can use any int, we will need to reference it later. $modelid = 1505347200; $upcomingseconds = new test_timesplitting_upcoming_seconds(); $upcomingseconds->set_modelid($modelid); $upcomingseconds->set_analysable($analysable); // Store the ranges we just obtained. $ranges = $upcomingseconds->get_all_ranges(); $nranges = count($ranges); $ntrainingranges = count($upcomingseconds->get_training_ranges()); $mostrecentrange = $upcomingseconds->get_most_recent_prediction_range(); $mostrecentrange = reset($mostrecentrange); // Mimic the modelfirstanalyses caching in \core_analytics\analysis. $this->mock_cache_first_analysis_caching($modelid, $analysable->get_id(), end($ranges)); // We wait for the next range to be added. sleep(1); // We set the analysable again so the time ranges are recalculated. $upcomingseconds->set_analysable($analysable); $newranges = $upcomingseconds->get_all_ranges(); $nnewranges = count($newranges); $nnewtrainingranges = $upcomingseconds->get_training_ranges(); $newmostrecentrange = $upcomingseconds->get_most_recent_prediction_range(); $newmostrecentrange = reset($newmostrecentrange); $this->assertGreaterThan($nranges, $nnewranges); $this->assertGreaterThan($ntrainingranges, $nnewtrainingranges); $this->assertGreaterThan($mostrecentrange['time'], $newmostrecentrange['time']); // All the ranges but the last one should return the same values. array_pop($ranges); array_pop($newranges); foreach ($ranges as $key => $range) { $this->assertEquals($newranges[$key]['start'], $range['start']); $this->assertEquals($newranges[$key]['end'], $range['end']); $this->assertEquals($newranges[$key]['time'], $range['time']); } } /** * Mocks core_analytics\analysis caching of the first time analysables were analysed. * * @param int $modelid * @param int $analysableid * @param array $range * @return null */ private function mock_cache_first_analysis_caching($modelid, $analysableid, $range) { $cache = \cache::make('core', 'modelfirstanalyses'); $cache->set($modelid . '_' . $analysableid, $range['time']); } } tests/indicator_test.php000064400000006143152266351020011440 0ustar00. namespace core_analytics; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/fixtures/test_indicator_max.php'); require_once(__DIR__ . '/fixtures/test_indicator_discrete.php'); require_once(__DIR__ . '/fixtures/test_indicator_min.php'); /** * Unit tests for the model. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class indicator_test extends \advanced_testcase { /** * test_validate_calculated_value * * @param string $indicatorclass * @param array $returnedvalue * @dataProvider validate_calculated_value * @return null */ public function test_validate_calculated_value($indicatorclass, $returnedvalue): void { $indicator = new $indicatorclass(); list($values, $unused) = $indicator->calculate([1], 'notrelevanthere'); $this->assertEquals($returnedvalue, $values[0]); } /** * Data provider for test_validate_calculated_value * * @return array */ public static function validate_calculated_value(): array { return [ 'max' => ['test_indicator_max', [1]], 'min' => ['test_indicator_min', [-1]], 'discrete' => ['test_indicator_discrete', [0, 0, 0, 0, 1]], ]; } /** * test_validate_calculated_value_exceptions * * @param string $indicatorclass * @param string $willreturn * @dataProvider validate_calculated_value_exceptions * @return null */ public function test_validate_calculated_value_exceptions($indicatorclass, $willreturn): void { $indicator = new $indicatorclass(); $indicatormock = $this->getMockBuilder(get_class($indicator)) ->onlyMethods(['calculate_sample']) ->getMock(); $indicatormock->method('calculate_sample')->willReturn($willreturn); $this->expectException(\coding_exception::class); list($values, $unused) = $indicatormock->calculate([1], 'notrelevanthere'); } /** * Data provider for test_validate_calculated_value_exceptions * * @return array */ public static function validate_calculated_value_exceptions(): array { return [ 'max' => ['test_indicator_max', 2], 'min' => ['test_indicator_min', -2], 'discrete' => ['test_indicator_discrete', 7], ]; } } tests/dataset_manager_test.php000064400000016225152266351020012605 0ustar00. namespace core_analytics; /** * Unit tests for the dataset manager. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class dataset_manager_test extends \advanced_testcase { /** @var array Store dataset top rows. */ protected array $sharedtoprows = []; /** * setUp * * @return null */ public function setUp(): void { parent::setUp(); $this->resetAfterTest(true); $this->sharedtoprows = array( array('var1', 'var2'), array('value1', 'value2'), array('header1', 'header2') ); } /** * test_create_dataset * * @return null */ public function test_create_dataset(): void { $dataset1 = new \core_analytics\dataset_manager(1, 1, 'whatever', \core_analytics\dataset_manager::LABELLED_FILEAREA, false); $dataset1data = array_merge($this->sharedtoprows, array(array('yeah', 'yeah', 'yeah'))); $f1 = $dataset1->store($dataset1data); $f1contents = $f1->get_content(); $this->assertStringContainsString('yeah', $f1contents); $this->assertStringContainsString('var1', $f1contents); $this->assertStringContainsString('value1', $f1contents); $this->assertStringContainsString('header1', $f1contents); } /** * test_merge_datasets * * @return null */ public function test_merge_datasets(): void { $dataset1 = new \core_analytics\dataset_manager(1, 1, 'whatever', \core_analytics\dataset_manager::LABELLED_FILEAREA, false); $dataset1data = array_merge($this->sharedtoprows, array(array('yeah', 'yeah', 'yeah'))); $f1 = $dataset1->store($dataset1data); $dataset2 = new \core_analytics\dataset_manager(1, 2, 'whatever', \core_analytics\dataset_manager::LABELLED_FILEAREA, false); $dataset2data = array_merge($this->sharedtoprows, array(array('no', 'no', 'no'))); $f2 = $dataset2->store($dataset2data); $files = array($f1, $f2); $merged = \core_analytics\dataset_manager::merge_datasets($files, 1, 'whatever', \core_analytics\dataset_manager::LABELLED_FILEAREA); $mergedfilecontents = $merged->get_content(); $this->assertStringContainsString('yeah', $mergedfilecontents); $this->assertStringContainsString('no', $mergedfilecontents); $this->assertStringContainsString('var1', $mergedfilecontents); $this->assertStringContainsString('value1', $mergedfilecontents); $this->assertStringContainsString('header1', $mergedfilecontents); } /** * test_get_pending_files * * @return null */ public function test_get_pending_files(): void { global $DB; $this->resetAfterTest(); $fakemodelid = 123; $timesplittingids = array( '\core\analytics\time_splitting\quarters', '\core\analytics\time_splitting\quarters_accum', ); // No files. $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, true, $timesplittingids)); $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids)); // We will reuse this analysable file to create training and prediction datasets (analysable level files are // merged into training and prediction files). $analysabledataset = new \core_analytics\dataset_manager($fakemodelid, 1, 'whatever', \core_analytics\dataset_manager::LABELLED_FILEAREA, false); $analysabledatasetdata = array_merge($this->sharedtoprows, array(array('yeah', 'yeah', 'yeah'))); $file = $analysabledataset->store($analysabledatasetdata); // Evaluation files ignored. $evaluationdataset = \core_analytics\dataset_manager::merge_datasets(array($file), $fakemodelid, '\core\analytics\time_splitting\quarters', \core_analytics\dataset_manager::LABELLED_FILEAREA, true); $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, true, $timesplittingids)); $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids)); // Training and prediction files are not mixed up. $trainingfile1 = \core_analytics\dataset_manager::merge_datasets(array($file), $fakemodelid, '\core\analytics\time_splitting\quarters', \core_analytics\dataset_manager::LABELLED_FILEAREA, false); $this->waitForSecond(); $trainingfile2 = \core_analytics\dataset_manager::merge_datasets(array($file), $fakemodelid, '\core\analytics\time_splitting\quarters', \core_analytics\dataset_manager::LABELLED_FILEAREA, false); $bytimesplitting = \core_analytics\dataset_manager::get_pending_files($fakemodelid, true, $timesplittingids); $this->assertFalse(isset($bytimesplitting['\core\analytics\time_splitting\quarters_accum'])); $this->assertCount(2, $bytimesplitting['\core\analytics\time_splitting\quarters']); $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids)); $predictionfile = \core_analytics\dataset_manager::merge_datasets(array($file), $fakemodelid, '\core\analytics\time_splitting\quarters', \core_analytics\dataset_manager::UNLABELLED_FILEAREA, false); $bytimesplitting = \core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids); $this->assertFalse(isset($bytimesplitting['\core\analytics\time_splitting\quarters_accum'])); $this->assertCount(1, $bytimesplitting['\core\analytics\time_splitting\quarters']); // Already used for training and prediction are discarded. $usedfile = (object)['modelid' => $fakemodelid, 'fileid' => $trainingfile1->get_id(), 'action' => 'trained', 'time' => time()]; $DB->insert_record('analytics_used_files', $usedfile); $bytimesplitting = \core_analytics\dataset_manager::get_pending_files($fakemodelid, true, $timesplittingids); $this->assertCount(1, $bytimesplitting['\core\analytics\time_splitting\quarters']); $usedfile->fileid = $predictionfile->get_id(); $usedfile->action = 'predicted'; $DB->insert_record('analytics_used_files', $usedfile); $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids)); } } tests/behat/manage_models.feature000064400000007360152266351020013151 0ustar00@core @core_analytics @javascript Feature: Manage analytics models In order to manage analytics models As a manager I need to create and use a model Background: Given a Python Machine Learning backend server is configured And I change the Python Machine Learning backend to use external server # Turn off the course welcome message, so we can easily test other messages. And the following config values are set as admin: | onlycli | 0 | analytics | | sendcoursewelcomemessage | 0 | enrol_manual | | enableanalytics | 1 | | And the following "users" exist: | username | firstname | lastname | email | | manager1 | Manager | 1 | manager1@example.com | And the following "system role assigns" exist: | user | course | role | | manager1 | Acceptance test site | manager | And the following "analytics model" exist: | target | indicators | timesplitting | enabled | | \core_course\analytics\target\course_completion | \core\analytics\indicator\any_write_action,\core\analytics\indicator\read_actions | \core\analytics\time_splitting\single_range | true | And I log in as "manager1" And I navigate to "Analytics > Analytics models" in site administration Scenario: Create a model When I open the action menu in ".top-nav" "css_element" And I choose "Create model" in the open action menu And I set the field "Enabled" to "Enable" And I select "__core_course__analytics__target__course_completion" from the "target" singleselect And I set the field "Indicators" to "Read actions amount, Any write action in the course" And I select "__core__analytics__time_splitting__single_range" from the "timesplitting" singleselect And I press "Save changes" Then I should see "No predictions available yet" in the "Students at risk of not meeting the course completion conditions" "table_row" Scenario: Edit a model When I open the action menu in "Students at risk of not meeting the course completion conditions" "table_row" And I choose "Edit" in the open action menu And I click on "Read actions amount" "text" in the ".form-autocomplete-selection" "css_element" And I press "Save changes" And I should not see "Read actions amount" Scenario: Disable a model When I open the action menu in "Students at risk of not meeting the course completion conditions" "table_row" And I choose "Disable" in the open action menu Then I should see "Disabled model" in the "Students at risk of not meeting the course completion conditions" "table_row" Scenario: Export model When I open the action menu in "Students at risk of not meeting the course completion conditions" "table_row" And I choose "Export" in the open action menu And I click on "Actions" "link" in the "Students at risk of not meeting the course completion conditions" "table_row" And following "Export" should download a file that: | Contains file in zip | model-config.json | Scenario: Check invalid site elements When I open the action menu in "Students at risk of not meeting the course completion conditions" "table_row" And I choose "Invalid site elements" in the open action menu Then I should see "Invalid analysable elements" Scenario: Delete model When I open the action menu in "Students at risk of not meeting the course completion conditions" "table_row" And I choose "Delete" in the open action menu And I click on "Delete" "button" in the "Delete" "dialogue" Then I should not see "Students at risk of not meeting the course completion conditions" tests/behat/behat_analytics.php000064400000004253152266351020012642 0ustar00. // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. use Moodle\BehatExtension\Exception\SkippedException; require_once(__DIR__ . '/../../../lib/behat/behat_base.php'); /** * Steps definitions related to Analytics. * * @package core * @category test * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_analytics extends behat_base { /** * Check if mlbackend_python is configured. * * @Given /^a Python Machine Learning backend server is configured$/ */ public function backend_is_configured(): void { if (!defined('TEST_MLBACKEND_PYTHON_HOST')) { throw new SkippedException( 'The Python Machine Learning backend server must be setup to run tests' ); } } /** * Change the Python Machine Learning backend to use external server. * * @Given /^I change the Python Machine Learning backend to use external server$/ */ public function change_backend_to_external_server(): void { set_config('useserver', 1, 'mlbackend_python'); set_config('host', TEST_MLBACKEND_PYTHON_HOST, 'mlbackend_python'); set_config('port', TEST_MLBACKEND_PYTHON_PORT, 'mlbackend_python'); set_config('username', TEST_MLBACKEND_PYTHON_USERNAME, 'mlbackend_python'); set_config('password', TEST_MLBACKEND_PYTHON_PASSWORD, 'mlbackend_python'); } } tests/manager_test.php000064400000064237152266351020011106 0ustar00. namespace core_analytics; use core_analytics\tests\mlbackend_helper_trait; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/fixtures/test_indicator_max.php'); require_once(__DIR__ . '/fixtures/test_indicator_min.php'); require_once(__DIR__ . '/fixtures/test_indicator_fullname.php'); require_once(__DIR__ . '/fixtures/test_target_course_level_shortname.php'); /** * Unit tests for the core_analytics manager. * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_analytics\manager */ final class manager_test extends \advanced_testcase { use mlbackend_helper_trait; /** * test_deleted_context */ public function test_deleted_context(): void { global $DB; if (!self::is_mlbackend_python_configured()) { $this->markTestSkipped('mlbackend_python is not configured.'); } $this->resetAfterTest(true); $this->setAdminuser(); set_config('enabled_stores', 'logstore_standard', 'tool_log'); // Create some courses. $this->generate_courses(2, ['visible' => 0]); $this->generate_courses(2, ['visible' => 1]); $target = \core_analytics\manager::get_target('test_target_course_level_shortname'); $indicators = ['test_indicator_max', 'test_indicator_min', 'test_indicator_fullname']; foreach ($indicators as $key => $indicator) { $indicators[$key] = \core_analytics\manager::get_indicator($indicator); } $model = \core_analytics\model::create($target, $indicators); $modelobj = $model->get_model_obj(); $model->enable('\core\analytics\time_splitting\no_splitting'); $model->train(); $model->predict(); // Generate a prediction action to confirm that it is deleted when there is an important update. $predictions = $DB->get_records('analytics_predictions'); $prediction = reset($predictions); $prediction = new \core_analytics\prediction($prediction, ['whatever' => 'not used']); $prediction->action_executed(\core_analytics\prediction::ACTION_USEFUL, $model->get_target()); $predictioncontextid = $prediction->get_prediction_data()->contextid; $npredictions = $DB->count_records('analytics_predictions', ['contextid' => $predictioncontextid]); $npredictionactions = $DB->count_records( 'analytics_prediction_actions', ['predictionid' => $prediction->get_prediction_data()->id] ); $nindicatorcalc = $DB->count_records('analytics_indicator_calc', ['contextid' => $predictioncontextid]); \core_analytics\manager::cleanup(); // Nothing is incorrectly deleted. $this->assertEquals($npredictions, $DB->count_records( 'analytics_predictions', ['contextid' => $predictioncontextid] )); $this->assertEquals($npredictionactions, $DB->count_records( 'analytics_prediction_actions', ['predictionid' => $prediction->get_prediction_data()->id] )); $this->assertEquals($nindicatorcalc, $DB->count_records( 'analytics_indicator_calc', ['contextid' => $predictioncontextid] )); // Now we delete a context, the course predictions and prediction actions should be deleted. $deletedcontext = \context::instance_by_id($predictioncontextid); delete_course($deletedcontext->instanceid, false); \core_analytics\manager::cleanup(); $this->assertEmpty($DB->count_records('analytics_predictions', ['contextid' => $predictioncontextid])); $this->assertEmpty($DB->count_records( 'analytics_prediction_actions', ['predictionid' => $prediction->get_prediction_data()->id] )); $this->assertEmpty($DB->count_records('analytics_indicator_calc', ['contextid' => $predictioncontextid])); set_config('enabled_stores', '', 'tool_log'); get_log_manager(true); } /** * test_deleted_analysable */ public function test_deleted_analysable(): void { global $DB; if (!self::is_mlbackend_python_configured()) { $this->markTestSkipped('mlbackend_python is not configured.'); } $this->resetAfterTest(true); $this->setAdminuser(); set_config('enabled_stores', 'logstore_standard', 'tool_log'); $target = \core_analytics\manager::get_target('test_target_course_level_shortname'); $indicators = ['test_indicator_max', 'test_indicator_min', 'test_indicator_fullname']; foreach ($indicators as $key => $indicator) { $indicators[$key] = \core_analytics\manager::get_indicator($indicator); } $model = \core_analytics\model::create($target, $indicators); $modelobj = $model->get_model_obj(); $coursepredict1 = $this->getDataGenerator()->create_course(['visible' => 0]); $coursepredict2 = $this->getDataGenerator()->create_course(['visible' => 0]); $coursetrain1 = $this->getDataGenerator()->create_course(['visible' => 1]); $coursetrain2 = $this->getDataGenerator()->create_course(['visible' => 1]); $model->enable('\core\analytics\time_splitting\no_splitting'); $model->train(); $model->predict(); $this->assertNotEmpty($DB->count_records('analytics_predict_samples')); $this->assertNotEmpty($DB->count_records('analytics_train_samples')); $this->assertNotEmpty($DB->count_records('analytics_used_analysables')); // Now we delete an analysable, stored predict and training samples should be deleted. $deletedcontext = \context_course::instance($coursepredict1->id); delete_course($coursepredict1, false); \core_analytics\manager::cleanup(); $this->assertEmpty($DB->count_records('analytics_predict_samples', ['analysableid' => $coursepredict1->id])); $this->assertEmpty($DB->count_records('analytics_train_samples', ['analysableid' => $coursepredict1->id])); $this->assertEmpty($DB->count_records('analytics_used_analysables', ['analysableid' => $coursepredict1->id])); set_config('enabled_stores', '', 'tool_log'); get_log_manager(true); } /** * Tests for the {@link \core_analytics\manager::load_default_models_for_component()} implementation. */ public function test_load_default_models_for_component(): void { $this->resetAfterTest(); // Attempting to load builtin models should always work without throwing exception. \core_analytics\manager::load_default_models_for_component('core'); // Attempting to load from a core subsystem without its own subsystem directory. $this->assertSame([], \core_analytics\manager::load_default_models_for_component('core_access')); // Attempting to load from a non-existing subsystem. $this->assertSame([], \core_analytics\manager::load_default_models_for_component('core_nonexistingsubsystem')); // Attempting to load from a non-existing plugin of a known plugin type. $this->assertSame([], \core_analytics\manager::load_default_models_for_component('mod_foobarbazquaz12240996776')); // Attempting to load from a non-existing plugin type. $this->assertSame([], \core_analytics\manager::load_default_models_for_component('foo_bar2776327736558')); } /** * Tests for the {@link \core_analytics\manager::load_default_models_for_all_components()} implementation. */ public function test_load_default_models_for_all_components(): void { $this->resetAfterTest(); $models = \core_analytics\manager::load_default_models_for_all_components(); $this->assertTrue(is_array($models['core'])); $this->assertNotEmpty($models['core']); $this->assertNotEmpty($models['core'][0]['target']); $this->assertNotEmpty($models['core'][0]['indicators']); } /** * Tests for the successful execution of the {@link \core_analytics\manager::validate_models_declaration()}. */ public function test_validate_models_declaration(): void { $this->resetAfterTest(); // This is expected to run without an exception. $models = self::load_models_from_fixture_file('no_teaching'); \core_analytics\manager::validate_models_declaration($models); } /** * Tests for the exceptions thrown by {@link \core_analytics\manager::validate_models_declaration()}. * * @dataProvider validate_models_declaration_exceptions_provider * @param array $models Models declaration. * @param string $exception Expected coding exception message. */ public function test_validate_models_declaration_exceptions(array $models, string $exception): void { $this->resetAfterTest(); $this->expectException(\coding_exception::class); $this->expectExceptionMessage($exception); \core_analytics\manager::validate_models_declaration($models); } /** * Data provider for the {@link self::test_validate_models_declaration_exceptions()}. * * @return array of (string)testcase => [(array)models, (string)expected exception message] */ public static function validate_models_declaration_exceptions_provider(): array { return [ 'missing_target' => [ self::load_models_from_fixture_file('missing_target'), 'Missing target declaration', ], 'invalid_target' => [ self::load_models_from_fixture_file('invalid_target'), 'Invalid target classname', ], 'missing_indicators' => [ self::load_models_from_fixture_file('missing_indicators'), 'Missing indicators declaration', ], 'invalid_indicators' => [ self::load_models_from_fixture_file('invalid_indicators'), 'Invalid indicator classname', ], 'invalid_time_splitting' => [ self::load_models_from_fixture_file('invalid_time_splitting'), 'Invalid time splitting classname', ], 'invalid_time_splitting_fq' => [ self::load_models_from_fixture_file('invalid_time_splitting_fq'), 'Expecting fully qualified time splitting classname', ], 'invalid_enabled' => [ self::load_models_from_fixture_file('invalid_enabled'), 'Cannot enable a model without time splitting method specified', ], ]; } /** * Loads models as declared in the given fixture file. * * @param string $filename * @return array */ protected static function load_models_from_fixture_file(string $filename) { global $CFG; $models = null; require("{$CFG->dirroot}/analytics/tests/fixtures/db_analytics_php/{$filename}.php"); return $models; } /** * Test the implementation of the {@link \core_analytics\manager::create_declared_model()}. */ public function test_create_declared_model(): void { global $DB; $this->resetAfterTest(); $this->setAdminuser(); $declaration = [ 'target' => 'test_target_course_level_shortname', 'indicators' => [ 'test_indicator_max', 'test_indicator_min', 'test_indicator_fullname', ], ]; $declarationwithtimesplitting = array_merge($declaration, [ 'timesplitting' => '\core\analytics\time_splitting\no_splitting', ]); $declarationwithtimesplittingenabled = array_merge($declarationwithtimesplitting, [ 'enabled' => true, ]); // Check that no such model exists yet. $target = \core_analytics\manager::get_target('test_target_course_level_shortname'); $this->assertEquals(0, $DB->count_records('analytics_models', ['target' => $target->get_id()])); $this->assertFalse(\core_analytics\model::exists($target)); // Check that the model is created. $created = \core_analytics\manager::create_declared_model($declaration); $this->assertTrue($created instanceof \core_analytics\model); $this->assertTrue(\core_analytics\model::exists($target)); $this->assertEquals(1, $DB->count_records('analytics_models', ['target' => $target->get_id()])); $modelid = $created->get_id(); // Check that created models are disabled by default. $existing = new \core_analytics\model($modelid); $this->assertEquals(0, $existing->get_model_obj()->enabled); $this->assertEquals(0, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST)); // Let the admin enable the model. $existing->enable('\core\analytics\time_splitting\no_splitting'); $this->assertEquals(1, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST)); // Check that further calls create a new model. $repeated = \core_analytics\manager::create_declared_model($declaration); $this->assertTrue($repeated instanceof \core_analytics\model); $this->assertEquals(2, $DB->count_records('analytics_models', ['target' => $target->get_id()])); // Delete the models. $existing->delete(); $repeated->delete(); $this->assertEquals(0, $DB->count_records('analytics_models', ['target' => $target->get_id()])); $this->assertFalse(\core_analytics\model::exists($target)); // Create it again, this time with time splitting method specified. $created = \core_analytics\manager::create_declared_model($declarationwithtimesplitting); $this->assertTrue($created instanceof \core_analytics\model); $this->assertTrue(\core_analytics\model::exists($target)); $this->assertEquals(1, $DB->count_records('analytics_models', ['target' => $target->get_id()])); $modelid = $created->get_id(); // Even if the time splitting method was specified, the model is still not enabled automatically. $existing = new \core_analytics\model($modelid); $this->assertEquals(0, $existing->get_model_obj()->enabled); $this->assertEquals(0, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST)); $existing->delete(); // Let's define the model so that it is enabled by default. $enabled = \core_analytics\manager::create_declared_model($declarationwithtimesplittingenabled); $this->assertTrue($enabled instanceof \core_analytics\model); $this->assertTrue(\core_analytics\model::exists($target)); $this->assertEquals(1, $DB->count_records('analytics_models', ['target' => $target->get_id()])); $modelid = $enabled->get_id(); $existing = new \core_analytics\model($modelid); $this->assertEquals(1, $existing->get_model_obj()->enabled); $this->assertEquals(1, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST)); // Let the admin disable the model. $existing->update(0, false, false); $this->assertEquals(0, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST)); } /** * Test the implementation of the {@link \core_analytics\manager::update_default_models_for_component()}. */ public function test_update_default_models_for_component(): void { $this->resetAfterTest(); $this->setAdminuser(); $noteaching = \core_analytics\manager::get_target('\core_course\analytics\target\no_teaching'); $dropout = \core_analytics\manager::get_target('\core_course\analytics\target\course_dropout'); $upcomingactivities = \core_analytics\manager::get_target('\core_user\analytics\target\upcoming_activities_due'); $norecentaccesses = \core_analytics\manager::get_target('\core_course\analytics\target\no_recent_accesses'); $noaccesssincestart = \core_analytics\manager::get_target('\core_course\analytics\target\no_access_since_course_start'); $this->assertTrue(\core_analytics\model::exists($noteaching)); $this->assertTrue(\core_analytics\model::exists($dropout)); $this->assertTrue(\core_analytics\model::exists($upcomingactivities)); $this->assertTrue(\core_analytics\model::exists($norecentaccesses)); $this->assertTrue(\core_analytics\model::exists($noaccesssincestart)); foreach (\core_analytics\manager::get_all_models() as $model) { $model->delete(); } $this->assertFalse(\core_analytics\model::exists($noteaching)); $this->assertFalse(\core_analytics\model::exists($dropout)); $this->assertFalse(\core_analytics\model::exists($upcomingactivities)); $this->assertFalse(\core_analytics\model::exists($norecentaccesses)); $this->assertFalse(\core_analytics\model::exists($noaccesssincestart)); $updated = \core_analytics\manager::update_default_models_for_component('moodle'); $this->assertEquals(5, count($updated)); $this->assertTrue(array_pop($updated) instanceof \core_analytics\model); $this->assertTrue(array_pop($updated) instanceof \core_analytics\model); $this->assertTrue(array_pop($updated) instanceof \core_analytics\model); $this->assertTrue(array_pop($updated) instanceof \core_analytics\model); $this->assertTrue(array_pop($updated) instanceof \core_analytics\model); $this->assertTrue(\core_analytics\model::exists($noteaching)); $this->assertTrue(\core_analytics\model::exists($dropout)); $this->assertTrue(\core_analytics\model::exists($upcomingactivities)); $this->assertTrue(\core_analytics\model::exists($norecentaccesses)); $this->assertTrue(\core_analytics\model::exists($noaccesssincestart)); $repeated = \core_analytics\manager::update_default_models_for_component('moodle'); $this->assertSame([], $repeated); } /** * test_get_time_splitting_methods description * @return null */ public function test_get_time_splitting_methods(): void { $this->resetAfterTest(true); $all = \core_analytics\manager::get_all_time_splittings(); $this->assertArrayHasKey('\core\analytics\time_splitting\upcoming_week', $all); $this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $all); $allforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(true); $this->assertArrayNotHasKey('\core\analytics\time_splitting\upcoming_week', $allforevaluation); $this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $allforevaluation); $defaultforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(false); $this->assertArrayNotHasKey('\core\analytics\time_splitting\upcoming_week', $defaultforevaluation); $this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $defaultforevaluation); $sometimesplittings = '\core\analytics\time_splitting\single_range,' . '\core\analytics\time_splitting\tenths'; set_config('defaulttimesplittingsevaluation', $sometimesplittings, 'analytics'); $defaultforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(false); $this->assertArrayNotHasKey('\core\analytics\time_splitting\quarters', $defaultforevaluation); } /** * Test the implementation of the {@link \core_analytics\manager::model_declaration_identifier()}. */ public function test_model_declaration_identifier(): void { $noteaching1 = self::load_models_from_fixture_file('no_teaching'); $noteaching2 = self::load_models_from_fixture_file('no_teaching'); $noteaching3 = self::load_models_from_fixture_file('no_teaching'); // Same model declaration should always lead to same identifier. $this->assertEquals( \core_analytics\manager::model_declaration_identifier(reset($noteaching1)), \core_analytics\manager::model_declaration_identifier(reset($noteaching2)) ); // If something is changed, the identifier should change, too. $noteaching2[0]['target'] .= '_'; $this->assertNotEquals( \core_analytics\manager::model_declaration_identifier(reset($noteaching1)), \core_analytics\manager::model_declaration_identifier(reset($noteaching2)) ); $noteaching3[0]['indicators'][] = '\core_analytics\local\indicator\binary'; $this->assertNotEquals( \core_analytics\manager::model_declaration_identifier(reset($noteaching1)), \core_analytics\manager::model_declaration_identifier(reset($noteaching3)) ); // The identifier is supposed to contain PARAM_ALPHANUM only. $this->assertEquals( \core_analytics\manager::model_declaration_identifier(reset($noteaching1)), clean_param(\core_analytics\manager::model_declaration_identifier(reset($noteaching1)), PARAM_ALPHANUM) ); $this->assertEquals( \core_analytics\manager::model_declaration_identifier(reset($noteaching2)), clean_param(\core_analytics\manager::model_declaration_identifier(reset($noteaching2)), PARAM_ALPHANUM) ); $this->assertEquals( \core_analytics\manager::model_declaration_identifier(reset($noteaching3)), clean_param(\core_analytics\manager::model_declaration_identifier(reset($noteaching3)), PARAM_ALPHANUM) ); } /** * Tests for the {@link \core_analytics\manager::get_declared_target_and_indicators_instances()}. */ public function test_get_declared_target_and_indicators_instances(): void { $this->resetAfterTest(); $definition = self::load_models_from_fixture_file('no_teaching'); [$target, $indicators] = \core_analytics\manager::get_declared_target_and_indicators_instances($definition[0]); $this->assertTrue($target instanceof \core_analytics\local\target\base); $this->assertNotEmpty($indicators); $this->assertContainsOnlyInstancesOf(\core_analytics\local\indicator\base::class, $indicators); } /** * test_get_potential_context_restrictions description */ public function test_get_potential_context_restrictions(): void { $this->resetAfterTest(); // No potential context restrictions. $this->assertFalse(\core_analytics\manager::get_potential_context_restrictions([])); $defaultcategory = \core_course_category::get_default(); $defaultcategorycontext = $defaultcategory->get_context(); // Include the all context levels so the misc. category get included. $this->assertEquals([ $defaultcategorycontext->id => "Category: {$defaultcategory->name}", ], manager::get_potential_context_restrictions()); $category = $this->getDataGenerator()->create_category(['name' => 'My category']); $categorycontext = $category->get_context(); $courseone = $this->getDataGenerator()->create_course(['fullname' => 'Course one', 'shortname' => 'CS1']); $courseonecontext = \context_course::instance($courseone->id); $coursetwo = $this->getDataGenerator()->create_course(['fullname' => 'Course two', 'shortname' => 'CS2']); $coursetwocontext = \context_course::instance($coursetwo->id); // All context levels. $this->assertEqualsCanonicalizing([ $defaultcategorycontext->id => "Category: {$defaultcategory->name}", $categorycontext->id => "Category: {$category->name}", $courseonecontext->id => "Course: {$courseone->shortname}", $coursetwocontext->id => "Course: {$coursetwo->shortname}", ], manager::get_potential_context_restrictions()); // All category/course context levels. $this->assertEqualsCanonicalizing([ $defaultcategorycontext->id => "Category: {$defaultcategory->name}", $categorycontext->id => "Category: {$category->name}", $courseonecontext->id => "Course: {$courseone->shortname}", $coursetwocontext->id => "Course: {$coursetwo->shortname}", ], manager::get_potential_context_restrictions([CONTEXT_COURSECAT, CONTEXT_COURSE])); // All category context levels. $this->assertEqualsCanonicalizing([ $defaultcategorycontext->id => "Category: {$defaultcategory->name}", $categorycontext->id => "Category: {$category->name}", ], manager::get_potential_context_restrictions([CONTEXT_COURSECAT])); // Filtered category context levels. $this->assertEquals([ $categorycontext->id => "Category: {$category->name}", ], manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'My cat')); $this->assertEmpty(manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'nothing')); // All course context levels. $this->assertEqualsCanonicalizing([ $courseonecontext->id => "Course: {$courseone->shortname}", $coursetwocontext->id => "Course: {$coursetwo->shortname}", ], manager::get_potential_context_restrictions([CONTEXT_COURSE])); // Filtered course context levels. $this->assertEquals([ $courseonecontext->id => "Course: {$courseone->shortname}", ], manager::get_potential_context_restrictions([CONTEXT_COURSE], 'one')); $this->assertEmpty(manager::get_potential_context_restrictions([CONTEXT_COURSE], 'nothing')); } } indicator/user_track_forums.php000064400000004150152266360770013004 0ustar00. /** * User tracks forums indicator. * * @package core_user * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_user\analytics\indicator; defined('MOODLE_INTERNAL') || die(); /** * User tracks forums indicator. * * @package core_user * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class user_track_forums extends \core_analytics\local\indicator\binary { /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:userforumstracking'); } /** * required_sample_data * * @return string[] */ public static function required_sample_data() { return array('user'); } /** * calculate_sample * * @param int $sampleid * @param string $samplesorigin * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, $samplesorigin, $starttime = false, $endtime = false) { $user = $this->retrieve('user', $sampleid); return ($user->trackforums) ? self::get_max_value() : self::get_min_value(); } } indicator/user_profile_set.php000064400000005545152266360770012631 0ustar00. /** * User profile set indicator. * * @package core_user * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_user\analytics\indicator; defined('MOODLE_INTERNAL') || die(); /** * User profile set indicator. * * @package core_user * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class user_profile_set extends \core_analytics\local\indicator\linear { /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:completeduserprofile'); } /** * required_sample_data * * @return string[] */ public static function required_sample_data() { return array('user'); } /** * calculate_sample * * @param int $sampleid * @param string $sampleorigin * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, $sampleorigin, $starttime = false, $endtime = false) { global $CFG; $user = $this->retrieve('user', $sampleid); // Nothing set results in -1. $calculatedvalue = self::MIN_VALUE; if (\core_user::awaiting_action($user)) { return self::MIN_VALUE; } if (!$user->confirmed) { return self::MIN_VALUE; } if ($user->description != '') { $calculatedvalue += 1; } if ($user->picture != '') { $calculatedvalue += 1; } // 0.2 for any of the following fields being set (some of them may even be compulsory or have a default). $fields = array('institution', 'department', 'address', 'city', 'country'); foreach ($fields as $fieldname) { if ($user->{$fieldname} != '') { $calculatedvalue += 0.2; } } return $this->limit_value($calculatedvalue); } } target/upcoming_activities_due.php000064400000022771152266360770013474 0ustar00. /** * Upcoming activities due target. * * @package core * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_user\analytics\target; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/lib/enrollib.php'); /** * Upcoming activities due target. * * @package core * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class upcoming_activities_due extends \core_analytics\local\target\binary { /** * Machine learning backends are not required to predict. * * @return bool */ public static function based_on_assumptions() { return true; } /** * Only update last analysis time when analysables are processed. * @return bool */ public function always_update_analysis_time(): bool { return false; } /** * Only upcoming stuff. * * @param \core_analytics\local\time_splitting\base $timesplitting * @return bool */ public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool { return ($timesplitting instanceof \core_analytics\local\time_splitting\after_now); } /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('target:upcomingactivitiesdue', 'user'); } /** * Overwritten to show a simpler language string. * * @param int $modelid * @param \context $context * @return string */ public function get_insight_subject(int $modelid, \context $context) { return get_string('youhaveupcomingactivitiesdue'); } /** * classes_description * * @return string[] */ protected static function classes_description() { return array( get_string('no'), get_string('yes'), ); } /** * Returns the predicted classes that will be ignored. * * @return array */ public function ignored_predicted_classes() { // No need to process users without upcoming activities due. return array(0); } /** * get_analyser_class * * @return string */ public function get_analyser_class() { return '\core\analytics\analyser\users'; } /** * All users are ok. * * @param \core_analytics\analysable $analysable * @param mixed $fortraining * @return true|string */ public function is_valid_analysable(\core_analytics\analysable $analysable, $fortraining = true) { // The calendar API used by \core_course\analytics\indicator\activities_due is already checking // if the user has any courses. return true; } /** * Samples are users and all of them are ok. * * @param int $sampleid * @param \core_analytics\analysable $analysable * @param bool $fortraining * @return bool */ public function is_valid_sample($sampleid, \core_analytics\analysable $analysable, $fortraining = true) { return true; } /** * Calculation based on activities due indicator. * * @param int $sampleid * @param \core_analytics\analysable $analysable * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) { $activitiesdueindicator = $this->retrieve('\core_course\analytics\indicator\activities_due', $sampleid); if ($activitiesdueindicator == \core_course\analytics\indicator\activities_due::get_max_value()) { return 1; } return 0; } /** * No need to link to the insights report in this case. * * @return bool */ public function link_insights_report(): bool { return false; } /** * Returns the body message for an insight of a single prediction. * * This default method is executed when the analysable used by the model generates one insight * for each analysable (one_sample_per_analysable === true) * * @param \context $context * @param \stdClass $user * @param \core_analytics\prediction $prediction * @param \core_analytics\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) { global $OUTPUT; $fullmessageplaintext = get_string('youhaveupcomingactivitiesdueinfo', 'moodle', $user->firstname); $sampledata = $prediction->get_sample_data(); $activitiesdue = $sampledata['core_course\analytics\indicator\activities_due:extradata']; if (empty($activitiesdue)) { // We can throw an exception here because this is a target based on assumptions and we require the // activities_due indicator. throw new \coding_exception('The activities_due indicator must be part of the model indicators.'); } $activitiestext = []; foreach ($activitiesdue as $key => $activitydue) { // Human-readable version. $activitiesdue[$key]->formattedtime = userdate($activitydue->time); // We provide the URL to the activity through a script that records the user click. $activityurl = new \moodle_url($activitydue->url); $actionurl = \core_analytics\prediction_action::transform_to_forward_url($activityurl, 'viewupcoming', $prediction->get_prediction_data()->id); $activitiesdue[$key]->url = $actionurl->out(false); if (count($activitiesdue) === 1) { // We will use this activity as the main URL of this insight. $insighturl = $actionurl; } $activitiestext[] = $activitydue->name . ': ' . $activitiesdue[$key]->url; } foreach ($actions as $key => $action) { if ($action->get_action_name() === 'viewupcoming') { // Use it as the main URL of the insight if there are multiple activities due. if (empty($insighturl)) { $insighturl = $action->get_url(); } // Remove the 'viewupcoming' action from the list of actions for this prediction as the action has // been included in the link to the activity. unset($actions[$key]); break; } } $activitieshtml = $OUTPUT->render_from_template('core_user/upcoming_activities_due_insight_body', (object) [ 'activitiesdue' => array_values($activitiesdue), 'userfirstname' => $user->firstname ]); return [ FORMAT_PLAIN => $fullmessageplaintext . PHP_EOL . PHP_EOL . implode(PHP_EOL, $activitiestext) . PHP_EOL, FORMAT_HTML => $activitieshtml, 'url' => $insighturl, ]; } /** * Adds a view upcoming events action. * * @param \core_analytics\prediction $prediction * @param mixed $includedetailsaction * @param bool $isinsightuser * @return \core_analytics\prediction_action[] */ public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false, $isinsightuser = false) { global $CFG, $USER; $parentactions = parent::prediction_actions($prediction, $includedetailsaction, $isinsightuser); if (!$isinsightuser && $USER->id != $prediction->get_prediction_data()->sampleid) { return $parentactions; } // We force a lookahead of 30 days so we are sure that the upcoming activities due are shown. $url = new \moodle_url('/calendar/view.php', ['view' => 'upcoming', 'lookahead' => '30']); $pix = new \pix_icon('i/calendar', get_string('viewupcomingactivitiesdue', 'calendar')); $action = new \core_analytics\prediction_action('viewupcoming', $prediction, $url, $pix, get_string('viewupcomingactivitiesdue', 'calendar')); return array_merge([$action], $parentactions); } } indicator/potential_social_breadth.php000064400000012331152266532100014255 0ustar00. /** * Potential social breadth indicator. * * @package core_course * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\analytics\indicator; defined('MOODLE_INTERNAL') || die(); use \core_analytics\local\indicator\community_of_inquiry_activity; /** * Potential social breadth indicator. * * It extends linear instead of discrete as there is a linear relation between * the different social levels activities can reach. * * @package core_course * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class potential_social_breadth extends \core_analytics\local\indicator\linear { /** * get_name * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:potentialsocial', 'moodle'); } /** * Specify the required data to process this indicator. * * @return string[] */ public static function required_sample_data() { // We require course because, although this indicator can also work with course_modules we can't // calculate anything without the course. return array('course'); } /** * calculate_sample * * @param int $sampleid * @param string $sampleorigin * @param int|false $notusedstarttime * @param int|false $notusedendtime * @return float */ public function calculate_sample($sampleid, $sampleorigin, $notusedstarttime = false, $notusedendtime = false) { if ($sampleorigin === 'course_modules') { $cm = $this->retrieve('course_modules', $sampleid); $cminfo = \cm_info::create($cm); $socialbreadthindicator = $this->get_social_indicator($cminfo->modname); $potentiallevel = $socialbreadthindicator->get_social_breadth_level($cminfo); if ($potentiallevel > community_of_inquiry_activity::MAX_SOCIAL_LEVEL) { $this->level_not_accepted($potentiallevel); } } else { $course = $this->retrieve('course', $sampleid); $modinfo = get_fast_modinfo($course); $cms = $modinfo->get_cms(); if (!$cms) { return self::get_min_value(); } $potentiallevel = 0; foreach ($cms as $cm) { if (!$socialbreadthindicator = $this->get_social_indicator($cm->modname)) { continue; } $level = $socialbreadthindicator->get_social_breadth_level($cm); if ($level > community_of_inquiry_activity::MAX_SOCIAL_LEVEL) { $this->level_not_accepted($level); } if ($level > $potentiallevel) { $potentiallevel = $level; } } } // 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. if ($potentiallevel > 2) { $potentiallevel = 2; } // Supporting only social breadth level 1 and 2 the possible values are -1 or 1. $levelscore = round(self::get_max_value() - self::get_min_value(), 2); // We substract $levelscore because we want to start from the lower socre and there is no cognitive depth level 0. return self::get_min_value() + ($levelscore * $potentiallevel) - $levelscore; } /** * Returns the social breadth class of this indicator. * * @param string $modname * @return \core_analytics\local\indicator\base|false */ protected function get_social_indicator($modname) { $indicators = \core_analytics\manager::get_all_indicators(); foreach ($indicators as $indicator) { if ($indicator instanceof community_of_inquiry_activity && $indicator->get_indicator_type() === community_of_inquiry_activity::INDICATOR_SOCIAL && $indicator->get_activity_type() === $modname) { return $indicator; } } return false; } /** * Throw a \coding_exception. * * @param int $level */ protected function level_not_accepted($level) { throw new \coding_exception('Activities\' potential social breadth go from 1 to ' . community_of_inquiry_activity::MAX_SOCIAL_LEVEL . '.'); } } indicator/no_student.php000064400000005525152266532100011424 0ustar00. /** * No student indicator. * * @package core_course * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\analytics\indicator; defined('MOODLE_INTERNAL') || die(); /** * No student indicator. * * @package core_course * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class no_student extends \core_analytics\local\indicator\binary { /** * Student role ids. * * @var array|null */ protected $studentroleids = null; /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:nostudent', 'moodle'); } /** * required_sample_data * * @return string[] */ public static function required_sample_data() { // We require course because, although calculate_sample only reads context, we need the context to be course // or below. return array('context', 'course'); } /** * calculate_sample * * @param int $sampleid * @param string $sampleorigin * @param int|false $notusedstarttime * @param int|false $notusedendtime * @return float */ public function calculate_sample($sampleid, $sampleorigin, $notusedstarttime = false, $notusedendtime = false) { $context = $this->retrieve('context', $sampleid); if (is_null($this->studentroleids)) { $this->studentroleids = array_keys(get_archetype_roles('student')); } foreach ($this->studentroleids as $role) { // We look for roles, not enrolments as a student assigned at category level is supposed to be a // course student. $students = get_role_users($role, $context, false, 'u.id', 'u.id'); if ($students) { return self::get_max_value(); } } return self::get_min_value(); } } indicator/potential_cognitive_depth.php000064400000012166152266532100014473 0ustar00. /** * Potential cognitive depth indicator. * * @package core_course * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\analytics\indicator; defined('MOODLE_INTERNAL') || die(); use \core_analytics\local\indicator\community_of_inquiry_activity; /** * Potential cognitive depth indicator. * * It extends linear instead of discrete as there is a linear relation between * the different cognitive levels activities can reach. * * @package core_course * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class potential_cognitive_depth extends \core_analytics\local\indicator\linear { /** * get_name * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:potentialcognitive', 'moodle'); } /** * Specify the required data to process this indicator. * * @return string[] */ public static function required_sample_data() { // We require course because, although this indicator can also work with course_modules we can't // calculate anything without the course. return array('course'); } /** * calculate_sample * * @throws \coding_exception * @param int $sampleid * @param string $sampleorigin * @param int|false $notusedstarttime * @param int|false $notusedendtime * @return float */ public function calculate_sample($sampleid, $sampleorigin, $notusedstarttime = false, $notusedendtime = false) { if ($sampleorigin === 'course_modules') { $cm = $this->retrieve('course_modules', $sampleid); $cminfo = \cm_info::create($cm); $cognitivedepthindicator = $this->get_cognitive_indicator($cminfo->modname); $potentiallevel = $cognitivedepthindicator->get_cognitive_depth_level($cminfo); if ($potentiallevel > community_of_inquiry_activity::MAX_COGNITIVE_LEVEL) { throw new \coding_exception('Maximum cognitive depth level is ' . community_of_inquiry_activity::MAX_COGNITIVE_LEVEL . ', ' . $potentiallevel . ' provided by ' . get_class($this)); } } else { $course = $this->retrieve('course', $sampleid); $modinfo = get_fast_modinfo($course); $cms = $modinfo->get_cms(); if (!$cms) { return self::get_min_value(); } $potentiallevel = 0; foreach ($cms as $cm) { if (!$cognitivedepthindicator = $this->get_cognitive_indicator($cm->modname)) { continue; } $level = $cognitivedepthindicator->get_cognitive_depth_level($cm); if ($level > community_of_inquiry_activity::MAX_COGNITIVE_LEVEL) { throw new \coding_exception('Maximum cognitive depth level is ' . community_of_inquiry_activity::MAX_COGNITIVE_LEVEL . ', ' . $level . ' provided by ' . get_class($this)); } if ($level > $potentiallevel) { $potentiallevel = $level; } } } // Values from -1 to 1 range split in 5 parts (the max cognitive depth level). // Note that we divide by 4 because we start from -1. $levelscore = round((self::get_max_value() - self::get_min_value()) / 4, 2); // We substract $levelscore because we want to start from the lower score and there is no cognitive depth level 0. return self::get_min_value() + ($levelscore * $potentiallevel) - $levelscore; } /** * Returns the cognitive depth class of this indicator. * * @param string $modname * @return \core_analytics\local\indicator\base|false */ protected function get_cognitive_indicator($modname) { $indicators = \core_analytics\manager::get_all_indicators(); foreach ($indicators as $indicator) { if ($indicator instanceof community_of_inquiry_activity && $indicator->get_indicator_type() === community_of_inquiry_activity::INDICATOR_COGNITIVE && $indicator->get_activity_type() === $modname) { return $indicator; } } return false; } } indicator/completion_enabled.php000064400000005356152266532100013067 0ustar00. /** * Completion enabled set indicator. * * @package core_course * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\analytics\indicator; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/lib/completionlib.php'); /** * Completion enabled set indicator. * * @package core_course * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class completion_enabled extends \core_analytics\local\indicator\binary { /** * get_name * * @return new \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:completionenabled', 'moodle'); } /** * required_sample_data * * @return string[] */ public static function required_sample_data() { // Minimum course although it also accepts course_modules. return array('course'); } /** * Is completion enabled? Work both with courses and activities. * * @param int $sampleid * @param string $sampleorigin * @param int|false $notusedstarttime * @param int|false $notusedendtime * @return float */ public function calculate_sample($sampleid, $sampleorigin, $notusedstarttime = false, $notusedendtime = false) { $course = $this->retrieve('course', $sampleid); // It may not be available, but if it is the indicator checks if completion is enabled for the cm. $cm = $this->retrieve('course_modules', $sampleid); $completion = new \completion_info($course); if (!$completion->is_enabled($cm)) { $value = self::get_min_value(); } else if (!$cm && !$completion->has_criteria()) { // Course completion enabled with no criteria counts as nothing. $value = self::get_min_value(); } else { $value = self::get_max_value(); } return $value; } } indicator/activities_due.php000064400000007400152266532100012235 0ustar00. /** * Activities due indicator. * * @package core * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\analytics\indicator; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/calendar/externallib.php'); /** * Activities due indicator. * * @package core * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class activities_due extends \core_analytics\local\indicator\binary { /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:activitiesdue'); } /** * required_sample_data * * @return string[] */ public static function required_sample_data() { return array('user'); } /** * calculate_sample * * @param int $sampleid * @param string $sampleorigin * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, $sampleorigin, $starttime = false, $endtime = false) { $user = $this->retrieve('user', $sampleid); $actionevents = \core_calendar_external::get_calendar_action_events_by_timesort($starttime, $endtime, 0, 1, true, $user->id); $useractionevents = []; if ($actionevents->events) { // We first need to check that at least one of the core_calendar_provide_event_action // callbacks has the $userid param. foreach ($actionevents->events as $event) { $nparams = $this->get_provide_event_action_num_params($event->modulename); if ($nparams > 2) { // Just the basic info for the insight as we want a low memory usage. $useractionevents[$event->id] = (object)[ 'name' => $event->name, 'url' => $event->url, 'time' => $event->timesort, 'coursename' => $event->course->fullnamedisplay, 'icon' => $event->icon, ]; } } if (!empty($useractionevents)) { $this->add_shared_calculation_info($sampleid, $useractionevents); return self::get_max_value(); } } return self::get_min_value(); } /** * Returns the number of params declared in core_calendar_provide_event_action's implementation. * * @param string $modulename The module name * @return int */ private function get_provide_event_action_num_params(string $modulename) { $functionname = 'mod_' . $modulename . '_core_calendar_provide_event_action'; $reflection = new \ReflectionFunction($functionname); return $reflection->getNumberOfParameters(); } } indicator/no_teacher.php000064400000005575152266532100011356 0ustar00. /** * No teacher indicator. * * @package core_course * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\analytics\indicator; defined('MOODLE_INTERNAL') || die(); /** * No teacher indicator. * * @package core_course * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class no_teacher extends \core_analytics\local\indicator\binary { /** * Teacher role ids. * * @var array|null */ protected $teacherroleids = null; /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:noteacher', 'moodle'); } /** * required_sample_data * * @return string[] */ public static function required_sample_data() { // We require course because, although calculate_sample only reads context, we need the context to be course // or below. return array('context', 'course'); } /** * calculate_sample * * @param int $sampleid * @param string $sampleorigin * @param int|false $notusedstarttime * @param int|false $notusedendtime * @return float */ public function calculate_sample($sampleid, $sampleorigin, $notusedstarttime = false, $notusedendtime = false) { $context = $this->retrieve('context', $sampleid); if (is_null($this->teacherroleids)) { $this->teacherroleids = array_keys(get_archetype_roles('editingteacher') + get_archetype_roles('teacher')); } foreach ($this->teacherroleids as $role) { // We look for roles, not enrolments as a teacher assigned at category level is supposed to be a // course teacher. $teachers = get_role_users($role, $context, false, 'u.id', 'u.id'); if ($teachers) { return self::get_max_value(); } } return self::get_min_value(); } } target/no_recent_accesses.php000064400000011603152266532100012373 0ustar00. /** * No recent accesses. * * @package core_course * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\analytics\target; defined('MOODLE_INTERNAL') || die(); /** * No recent accesses. * * @package core_course * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class no_recent_accesses extends course_enrolments { /** * Machine learning backends are not required to predict. * * @return bool */ public static function based_on_assumptions() { return true; } /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('target:norecentaccesses', 'course'); } /** * Returns the body message for the insight. * * @param \context $context * @param string $contextname * @param \stdClass $user * @param \moodle_url $insighturl * @return array 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; $a = (object)['coursename' => $contextname, 'userfirstname' => $user->firstname]; $fullmessage = get_string('norecentaccessesinfomessage', 'course', $a) . PHP_EOL . PHP_EOL . $insighturl->out(false); $fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message', ['url' => $insighturl->out(false), 'insightinfomessage' => get_string('norecentaccessesinfomessage', 'course', $a)] ); return [$fullmessage, $fullmessagehtml]; } /** * Only past stuff whose start matches the course start. * * @param \core_analytics\local\time_splitting\base $timesplitting * @return bool */ public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool { return ($timesplitting instanceof \core_analytics\local\time_splitting\past_periodic); } /** * Discards courses that are not yet ready to be used for prediction. * * @param \core_analytics\analysable $course * @param bool $fortraining * @return true|string */ public function is_valid_analysable(\core_analytics\analysable $course, $fortraining = true) { if (!$course->was_started()) { return get_string('coursenotyetstarted', 'course'); } if (!$this->students = $course->get_students()) { return get_string('nocoursestudents', 'course'); } if (!$fortraining && !$course->get_course_data()->visible) { return get_string('hiddenfromstudents'); } if ($course->get_end() && $course->get_end() < $course->get_start()) { return get_string('errorendbeforestart', 'course'); } // Finished courses can not be used to get predictions. if (!$fortraining && $course->is_finished()) { return get_string('coursealreadyfinished', 'course'); } return true; } /** * Do the user has any read action in the course? * * @param int $sampleid * @param \core_analytics\analysable $analysable * @param int $starttime * @param int $endtime * @return float|null 0 -> accesses, 1 -> no accesses. */ protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) { if (!$this->enrolment_active_during_analysis_time($sampleid, $starttime, $endtime)) { // We should not use this sample as the analysis results could be misleading. return null; } $readactions = $this->retrieve('\core\analytics\indicator\any_course_access', $sampleid); if ($readactions == \core\analytics\indicator\any_course_access::get_min_value()) { return 1; } return 0; } } target/no_teaching.php000064400000016132152266532100011026 0ustar00. /** * No teaching target. * * @package core_course * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\analytics\target; defined('MOODLE_INTERNAL') || die(); /** * No teaching target. * * @package core_course * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class no_teaching extends \core_analytics\local\target\binary { /** * Machine learning backends are not required to predict. * * @return bool */ public static function based_on_assumptions() { return true; } /** * It requires a specific time-splitting method. * * @param \core_analytics\local\time_splitting\base $timesplitting * @return bool */ public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool { return (get_class($timesplitting) === \core\analytics\time_splitting\single_range::class); } /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('target:noteachingactivity', 'course'); } /** * Overwritten to show a simpler language string. * * @param int $modelid * @param \context $context * @return string */ public function get_insight_subject(int $modelid, \context $context) { return get_string('noteachingupcomingcourses'); } /** * Returns the body message for the insight. * * @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; $a = (object)['userfirstname' => $user->firstname]; $fullmessage = get_string('noteachinginfomessage', 'course', $a) . PHP_EOL . PHP_EOL . $insighturl->out(false); $fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message', ['url' => $insighturl->out(false), 'insightinfomessage' => get_string('noteachinginfomessage', 'course', $a)] ); return [$fullmessage, $fullmessagehtml]; } /** * prediction_actions * * @param \core_analytics\prediction $prediction * @param mixed $includedetailsaction * @param bool $isinsightuser * @return \core_analytics\prediction_action[] */ public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false, $isinsightuser = false) { global $CFG; require_once($CFG->dirroot . '/course/lib.php'); $sampledata = $prediction->get_sample_data(); $course = $sampledata['course']; $actions = array(); $url = new \moodle_url('/course/view.php', array('id' => $course->id)); $pix = new \pix_icon('i/course', get_string('course')); $actions[] = new \core_analytics\prediction_action('viewcourse', $prediction, $url, $pix, get_string('view')); if (course_can_view_participants($sampledata['context'])) { $url = new \moodle_url('/user/index.php', array('id' => $course->id)); $pix = new \pix_icon('i/cohort', get_string('participants')); $actions[] = new \core_analytics\prediction_action('viewparticipants', $prediction, $url, $pix, get_string('participants')); } $parentactions = parent::prediction_actions($prediction, $includedetailsaction, $isinsightuser); return array_merge($actions, $parentactions); } /** * classes_description * * @return string[] */ protected static function classes_description() { return array( get_string('targetlabelteachingyes', 'course'), get_string('targetlabelteachingno', 'course'), ); } /** * get_analyser_class * * @return string */ public function get_analyser_class() { return '\core\analytics\analyser\site_courses'; } /** * is_valid_analysable * * @param \core_analytics\analysable $analysable * @param mixed $fortraining * @return true|string */ public function is_valid_analysable(\core_analytics\analysable $analysable, $fortraining = true) { // The analysable is the site, so yes, it is always valid. return true; } /** * Only process samples which start date is getting close. * * @param int $sampleid * @param \core_analytics\analysable $analysable * @param bool $fortraining * @return bool */ public function is_valid_sample($sampleid, \core_analytics\analysable $analysable, $fortraining = true) { $course = $this->retrieve('course', $sampleid); $now = time(); // No courses without start date, no finished courses, no predictions before start - 1 week nor // predictions for courses that started more than 1 week ago. if (!$course->startdate || (!empty($course->enddate) && $course->enddate < $now) || $course->startdate - WEEKSECS > $now || $course->startdate + WEEKSECS < $now) { return false; } return true; } /** * calculate_sample * * @param int $sampleid * @param \core_analytics\analysable $analysable * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) { $noteachersindicator = $this->retrieve('\core_course\analytics\indicator\no_teacher', $sampleid); $nostudentsindicator = $this->retrieve('\core_course\analytics\indicator\no_student', $sampleid); if ($noteachersindicator == \core_course\analytics\indicator\no_teacher::get_min_value() || $nostudentsindicator == \core_course\analytics\indicator\no_student::get_min_value()) { // No teachers or no students :(. return 1; } return 0; } } target/course_dropout.php000064400000013272152266532100011626 0ustar00. /** * Drop out course target. * * @package core_course * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\analytics\target; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/course/lib.php'); require_once($CFG->dirroot . '/lib/gradelib.php'); require_once($CFG->dirroot . '/lib/completionlib.php'); require_once($CFG->dirroot . '/completion/completion_completion.php'); /** * Drop out course target. * * @package core_course * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class course_dropout extends course_enrolments { /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('target:coursedropout', 'course'); } /** * classes_description * * @return string[] */ protected static function classes_description() { return array( get_string('targetlabelstudentdropoutno', 'course'), get_string('targetlabelstudentdropoutyes', 'course') ); } /** * Discards courses that are not yet ready to be used for training or prediction. * * @param \core_analytics\analysable $course * @param bool $fortraining * @return true|string */ public function is_valid_analysable(\core_analytics\analysable $course, $fortraining = true) { global $DB; $isvalid = parent::is_valid_analysable($course, $fortraining); if (is_string($isvalid)) { return $isvalid; } if ($fortraining) { // Not a valid target for training if there are not enough course accesses between the course start and end dates. $params = array('courseid' => $course->get_id(), 'anonymous' => 0, 'start' => $course->get_start(), 'end' => $course->get_end()); list($studentssql, $studentparams) = $DB->get_in_or_equal($this->students, SQL_PARAMS_NAMED); // Using anonymous to use the db index, not filtering by timecreated to speed it up. $select = 'courseid = :courseid AND anonymous = :anonymous AND timecreated > :start AND timecreated < :end ' . 'AND userid ' . $studentssql; if (!$logstore = \core_analytics\manager::get_analytics_logstore()) { throw new \coding_exception('No available log stores'); } $nlogs = $logstore->get_events_select_count($select, array_merge($params, $studentparams)); // At least a minimum of students activity. $nstudents = count($this->students); if ($nlogs / $nstudents < 10) { return get_string('nocourseactivity', 'course'); } } return true; } /** * calculate_sample * * The meaning of a drop out changes depending on the settings enabled in the course. Following these priorities order: * 1.- Course completion * 2.- No logs during the last quarter of the course * * @param int $sampleid * @param \core_analytics\analysable $course * @param int $starttime * @param int $endtime * @return float|null 0 -> not at risk, 1 -> at risk */ protected function calculate_sample($sampleid, \core_analytics\analysable $course, $starttime = false, $endtime = false) { if (!$this->enrolment_active_during_analysis_time($sampleid, $starttime, $endtime)) { // We should not use this sample as the analysis results could be misleading. return null; } $userenrol = $this->retrieve('user_enrolments', $sampleid); // We use completion as a success metric only when it is enabled. $completion = new \completion_info($course->get_course_data()); if ($completion->is_enabled() && $completion->has_criteria()) { $ccompletion = new \completion_completion(array('userid' => $userenrol->userid, 'course' => $course->get_id())); if ($ccompletion->is_complete()) { return 0; } else { return 1; } } if (!$logstore = \core_analytics\manager::get_analytics_logstore()) { throw new \coding_exception('No available log stores'); } // No logs during the last quarter of the course. $courseduration = $course->get_end() - $course->get_start(); $limit = intval($course->get_end() - ($courseduration / 4)); $select = "courseid = :courseid AND userid = :userid AND timecreated > :limit"; $params = array('userid' => $userenrol->userid, 'courseid' => $course->get_id(), 'limit' => $limit); $nlogs = $logstore->get_events_select_count($select, $params); if ($nlogs == 0) { return 1; } return 0; } } target/course_gradetopass.php000064400000014074152266532100012447 0ustar00. /** * Getting the minimum grade to pass target. * * @package core_course * @copyright 2019 Victor Deniz * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\analytics\target; defined('MOODLE_INTERNAL') || die(); /** * Getting the minimum grade to pass target. * * @package core_course * @copyright 2019 Victor Deniz * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class course_gradetopass extends course_enrolments { /** * Courses grades to pass. * @var mixed[] */ protected $coursesgradetopass = array(); /** * Courses grades. * @var mixed[] */ protected $coursesgrades = array(); /** * Returns the grade to pass a course. * * Save the value in $coursesgradetopass array to prevent new accesses to the database. * * @param int $courseid The course id. * @return array The courseitem id and the required grade to pass the course. */ protected function get_course_gradetopass($courseid) { if (!isset($this->coursesgradetopass[$courseid])) { // Get course grade_item. $courseitem = \grade_item::fetch_course_item($courseid); $ci = array(); $ci['courseitemid'] = $courseitem->id; if ($courseitem->gradetype == GRADE_TYPE_VALUE && grade_floats_different($courseitem->gradepass, 0.0)) { $ci['gradetopass'] = $courseitem->gradepass; } else { $ci['gradetopass'] = null; } $this->coursesgradetopass[$courseid] = $ci; } return $this->coursesgradetopass[$courseid]; } /** * Returns the grade of a user in a course. * * Saves the grades of all course users in $coursesgrades array to prevent new accesses to the database. * * @param int $courseitemid The course item id. * @param int $userid the user whose grade is requested. * @return array The courseitem id and the required grade to pass the course. */ protected function get_user_grade($courseitemid, $userid) { // If the user grade for this course is not available, get all the grades for the course. if (!isset($this->coursesgrades[$courseitemid])) { // Ony a course is cached to avoid high memory usage. unset($this->coursesgrades); $gg = new \grade_grade(null, false); $usersgrades = $gg->fetch_all(array('itemid' => $courseitemid)); if ($usersgrades) { foreach ($usersgrades as $ug) { $this->coursesgrades[$courseitemid][$ug->userid] = $ug->finalgrade; } } } if (!isset($this->coursesgrades[$courseitemid][$userid])) { $this->coursesgrades[$courseitemid][$userid] = null; } return $this->coursesgrades[$courseitemid][$userid]; } /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('target:coursegradetopass', 'course'); } /** * Returns descriptions for each of the values the target calculation can return. * * @return string[] */ protected static function classes_description() { return array( get_string('targetlabelstudentgradetopassno', 'course'), get_string('targetlabelstudentgradetopassyes', 'course') ); } /** * Discards courses that are not yet ready to be used for training or prediction. * * Only courses with "value" grade type and grade to pass set are valid. * * @param \core_analytics\analysable $course * @param bool $fortraining * @return true|string */ public function is_valid_analysable(\core_analytics\analysable $course, $fortraining = true) { $isvalid = parent::is_valid_analysable($course, $fortraining); if (is_string($isvalid)) { return $isvalid; } $courseitem = $this->get_course_gradetopass ($course->get_id()); if (is_null($courseitem['gradetopass'])) { return get_string('gradetopassnotset', 'course'); } return true; } /** * The user's grade in the course sets the target value. * * @param int $sampleid * @param \core_analytics\analysable $course * @param int $starttime * @param int $endtime * @return float|null 0 -> course grade to pass achieved, 1 -> course grade to pass not achieved */ protected function calculate_sample($sampleid, \core_analytics\analysable $course, $starttime = false, $endtime = false) { if (!$this->enrolment_active_during_analysis_time($sampleid, $starttime, $endtime)) { // We should not use this sample as the analysis results could be misleading. return null; } $userenrol = $this->retrieve('user_enrolments', $sampleid); // Get course grade to pass. $courseitem = $this->get_course_gradetopass($course->get_id()); // Get the user grade. $usergrade = $this->get_user_grade($courseitem['courseitemid'], $userenrol->userid); if ($usergrade >= $courseitem['gradetopass']) { return 0; } return 1; } } target/course_enrolments.php000064400000035570152266532100012325 0ustar00. /** * Base class for targets whose analysable is a course using user enrolments as samples. * * @package core_course * @copyright 2019 Victor Deniz * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\analytics\target; defined('MOODLE_INTERNAL') || die(); /** * Base class for targets whose analysable is a course using user enrolments as samples. * * @package core_course * @copyright 2019 Victor Deniz * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class course_enrolments extends \core_analytics\local\target\binary { /** * @var string */ const MESSAGE_ACTION_NAME = 'studentmessage'; /** * @var float */ const ENROL_ACTIVE_PERCENT_REQUIRED = 0.7; /** * Students in the course. * @var int[] */ protected $students; /** * Returns the analyser class that should be used along with this target. * * @return string The full class name as a string */ public function get_analyser_class() { return '\core\analytics\analyser\student_enrolments'; } /** * Only past stuff. * * @param \core_analytics\local\time_splitting\base $timesplitting * @return bool */ public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool { return ($timesplitting instanceof \core_analytics\local\time_splitting\before_now); } /** * Overwritten to show a simpler language string. * * @param int $modelid * @param \context $context * @return string */ public function get_insight_subject(int $modelid, \context $context) { return get_string('studentsatriskincourse', 'course', $context->get_context_name(false)); } /** * Returns the body message for the insight. * * @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; $a = (object)['coursename' => $contextname, 'userfirstname' => $user->firstname]; $fullmessage = get_string('studentsatriskinfomessage', 'course', $a) . PHP_EOL . PHP_EOL . $insighturl->out(false); $fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message', ['url' => $insighturl->out(false), 'insightinfomessage' => get_string('studentsatriskinfomessage', 'course', $a)] ); return [$fullmessage, $fullmessagehtml]; } /** * Discards courses that are not yet ready to be used for training or prediction. * * @param \core_analytics\analysable $course * @param bool $fortraining * @return true|string */ public function is_valid_analysable(\core_analytics\analysable $course, $fortraining = true) { if (!$course->was_started()) { return get_string('coursenotyetstarted', 'course'); } if (!$fortraining && !$course->get_course_data()->visible) { return get_string('hiddenfromstudents'); } if (!$this->students = $course->get_students()) { return get_string('nocoursestudents', 'course'); } if (!course_format_uses_sections($course->get_course_data()->format)) { // We can not split activities in time ranges. return get_string('nocoursesections', 'course'); } if ($course->get_end() == 0) { // We require time end to be set. return get_string('nocourseendtime', 'course'); } if ($course->get_end() < $course->get_start()) { return get_string('errorendbeforestart', 'course'); } // A course that lasts longer than 1 year probably have wrong start or end dates. if ($course->get_end() - $course->get_start() > (YEARSECS + (WEEKSECS * 4))) { return get_string('coursetoolong', 'course'); } // Finished courses can not be used to get predictions. if (!$fortraining && $course->is_finished()) { return get_string('coursealreadyfinished', 'course'); } if ($fortraining) { // Ongoing courses data can not be used to train. if (!$course->is_finished()) { return get_string('coursenotyetfinished', 'course'); } } return true; } /** * Discard student enrolments that are invalid. * * Note that this method assumes that the target is only interested in enrolments that are/were active * between the current course start and end times. Targets interested in predicting students at risk before * their enrolment start and targets interested in getting predictions for students whose enrolment already * finished should overwrite this method as these students are discarded by this method. * * @param int $sampleid * @param \core_analytics\analysable $course * @param bool $fortraining * @return bool */ public function is_valid_sample($sampleid, \core_analytics\analysable $course, $fortraining = true) { $now = time(); $userenrol = $this->retrieve('user_enrolments', $sampleid); if ($userenrol->timeend && $course->get_start() > $userenrol->timeend) { // Discard enrolments which time end is prior to the course start. This should get rid of // old user enrolments that remain on the course. return false; } $limit = $course->get_start() - (YEARSECS + (WEEKSECS * 4)); if (($userenrol->timestart && $userenrol->timestart < $limit) || (!$userenrol->timestart && $userenrol->timecreated < $limit)) { // Following what we do in is_valid_analysable, we will discard enrolments that last more than 1 academic year // because they have incorrect start and end dates or because they are reused along multiple years // without removing previous academic years students. This may not be very accurate because some courses // can last just some months, but it is better than nothing. return false; } if ($course->get_end()) { if (($userenrol->timestart && $userenrol->timestart > $course->get_end()) || (!$userenrol->timestart && $userenrol->timecreated > $course->get_end())) { // Discard user enrolments that start after the analysable official end. return false; } } if ($now < $userenrol->timestart && $userenrol->timestart) { // Discard enrolments whose start date is after now (no need to check timecreated > $now :P). return false; } if (!$fortraining && $userenrol->timeend && $userenrol->timeend < $now) { // We don't want to generate predictions for finished enrolments. return false; } return true; } /** * prediction_actions * * @param \core_analytics\prediction $prediction * @param bool $includedetailsaction * @param bool $isinsightuser * @return \core_analytics\prediction_action[] */ public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false, $isinsightuser = false) { $actions = array(); $sampledata = $prediction->get_sample_data(); $studentid = $sampledata['user']->id; // View outline report. $url = new \moodle_url('/report/outline/user.php', array('id' => $studentid, 'course' => $sampledata['course']->id, 'mode' => 'outline')); $pix = new \pix_icon('i/report', get_string('outlinereport')); $actions[] = new \core_analytics\prediction_action('viewoutlinereport', $prediction, $url, $pix, get_string('outlinereport'), false, ['target' => '_blank']); return array_merge(parent::prediction_actions($prediction, $includedetailsaction, $isinsightuser), $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) { $actions = []; $userids = []; foreach ($predictions as $prediction) { $sampledata = $prediction->get_sample_data(); $userid = $sampledata['user']->id; // Indexed by prediction id because we want the predictionid-userid // mapping later when sending the message. $userids[$prediction->get_prediction_data()->id] = $userid; } // Send a message for all the students. $attrs = array( 'data-bulk-sendmessage' => '1', 'data-prediction-to-user-id' => json_encode($userids) ); $actions[] = new \core_analytics\bulk_action(self::MESSAGE_ACTION_NAME, new \moodle_url(''), new \pix_icon('t/message', get_string('sendmessage', 'message')), get_string('sendmessage', 'message'), true, $attrs); return array_merge($actions, parent::bulk_actions($predictions)); } /** * Adds the JS required to run the bulk actions. */ public function add_bulk_actions_js() { global $PAGE; $PAGE->requires->js_call_amd('report_insights/message_users', 'init', ['.insights-bulk-actions', self::MESSAGE_ACTION_NAME]); parent::add_bulk_actions_js(); } /** * Is/was this user enrolment active during most of the analysis interval? * * This method discards enrolments that were not active during most of the analysis interval. It is * important to discard these enrolments because the indicator calculations can lead to misleading * results. * * Note that this method assumes that the target is interested in enrolments that are/were active * during the analysis interval. Targets interested in predicting students at risk before * their enrolment start should not call this method. Similarly, targets interested in getting * predictions for students whose enrolment already finished should not call this method either. * * @param int $sampleid The id of the sample that is being calculated * @param int $starttime The analysis interval start time * @param int $endtime The analysis interval end time * @return bool */ protected function enrolment_active_during_analysis_time(int $sampleid, int $starttime, int $endtime) { $userenrol = $this->retrieve('user_enrolments', $sampleid); if (!empty($userenrol->timestart)) { $enrolstart = $userenrol->timestart; } else { // This is always set. $enrolstart = $userenrol->timecreated; } if (!empty($userenrol->timeend)) { $enrolend = $userenrol->timeend; } else { // Default to tre end of the world. $enrolend = PHP_INT_MAX; } if ($endtime && $endtime < $enrolstart) { /* The enrolment starts/ed after the analysis end time. * |=========| |----------| * A start A end E start E end */ return false; } if ($starttime && $enrolend < $starttime) { /* The enrolment finishes/ed before the analysis start time. * |---------| |==========| * E start E end A start A end */ return false; } // Now we want to discard enrolments that were not active for most of the analysis interval. We // need both a $starttime and an $endtime to calculate this. if (!$starttime) { // Early return. Nothing to discard if there is no start. return true; } if (!$endtime) { // We can not calculate in relative terms (percent) how far from the analysis start time // this enrolment start is/was. return true; } if ($enrolstart < $starttime && $endtime < $enrolend) { /* The enrolment is active during all the analysis time. * |-----------------------------| * |========| * E start A start A end E end */ return true; } // If we reach this point is because the enrolment is only active for a portion of the analysis interval. // Therefore, we check that it was active for most of the analysis interval, a self::ENROL_ACTIVE_PERCENT_REQUIRED. if ($starttime <= $enrolstart && $enrolend <= $endtime) { /* |=============================| * |--------| * A start E start E end A end */ $activeenrolduration = $enrolend - $enrolstart; } else if ($enrolstart <= $starttime && $enrolend <= $endtime) { /* |===================| * |------------------| * E start A start E end A end */ $activeenrolduration = $enrolend - $starttime; } else if ($starttime <= $enrolstart && $endtime <= $enrolend) { /* |===================| * |------------------| * A start E start A end E end */ $activeenrolduration = $endtime - $enrolstart; } $analysisduration = $endtime - $starttime; if (floatval($activeenrolduration) / floatval($analysisduration) < self::ENROL_ACTIVE_PERCENT_REQUIRED) { // The student was not enroled in the course for most of the analysis interval. return false; } // We happily return true if the enrolment was active for more than self::ENROL_ACTIVE_PERCENT_REQUIRED of // the analysis interval. return true; } } target/no_access_since_course_start.php000064400000005602152266532100014463 0ustar00. /** * No accesses since the start of the course. * * @package core_course * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\analytics\target; defined('MOODLE_INTERNAL') || die(); /** * No accesses since the start of the course. * * @package core_course * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class no_access_since_course_start extends no_recent_accesses { /** * Only past stuff whose start matches the course start. * * @param \core_analytics\local\time_splitting\base $timesplitting * @return bool */ public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool { return ($timesplitting instanceof \core_analytics\local\time_splitting\after_start); } /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('target:noaccesssincecoursestart', 'course'); } /** * Returns the body message for the insight. * * @param \context $context * @param string $contextname * @param \stdClass $user * @param \moodle_url $insighturl * @return array 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; $a = (object)['coursename' => $contextname, 'userfirstname' => $user->firstname]; $fullmessage = get_string('noaccesssincestartinfomessage', 'course', $a) . PHP_EOL . PHP_EOL . $insighturl->out(false); $fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message', ['url' => $insighturl->out(false), 'insightinfomessage' => get_string('noaccesssincestartinfomessage', 'course', $a)] ); return [$fullmessage, $fullmessagehtml]; } } target/course_competencies.php000064400000011212152266532100012600 0ustar00. /** * Course competencies achievement target. * * @package core_course * @copyright 2019 Victor Deniz * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\analytics\target; defined('MOODLE_INTERNAL') || die(); /** * Course competencies achievement target. * * @package core_course * @copyright 2019 Victor Deniz * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class course_competencies extends course_enrolments { /** * Number of competencies assigned per course. * @var int[] */ protected $coursecompetencies = array(); /** * Count the competencies in a course. * * Save the value in $coursecompetencies array to prevent new accesses to the database. * * @param int $courseid The course id. * @return int Number of competencies assigned to the course. */ protected function get_num_competencies_in_course($courseid) { if (!isset($this->coursecompetencies[$courseid])) { $ccs = \core_competency\api::count_competencies_in_course($courseid); // Save the number of competencies per course to avoid another database access in calculate_sample(). $this->coursecompetencies[$courseid] = $ccs; } else { $ccs = $this->coursecompetencies[$courseid]; } return $ccs; } /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('target:coursecompetencies', 'course'); } /** * Returns descriptions for each of the values the target calculation can return. * * @return string[] */ protected static function classes_description() { return array( get_string('targetlabelstudentcompetenciesno', 'course'), get_string('targetlabelstudentcompetenciesyes', 'course'), ); } /** * Discards courses that are not yet ready to be used for training or prediction. * * @param \core_analytics\analysable $course * @param bool $fortraining * @return true|string */ public function is_valid_analysable(\core_analytics\analysable $course, $fortraining = true) { $isvalid = parent::is_valid_analysable($course, $fortraining); if (is_string($isvalid)) { return $isvalid; } $ccs = $this->get_num_competencies_in_course($course->get_id()); if (!$ccs) { return get_string('nocompetenciesincourse', 'tool_lp'); } return true; } /** * To have the proficiency or not in each of the competencies assigned to the course sets the target value. * * @param int $sampleid * @param \core_analytics\analysable $course * @param int $starttime * @param int $endtime * @return float|null 0 -> competencies achieved, 1 -> competencies not achieved */ protected function calculate_sample($sampleid, \core_analytics\analysable $course, $starttime = false, $endtime = false) { if (!$this->enrolment_active_during_analysis_time($sampleid, $starttime, $endtime)) { // We should not use this sample as the analysis results could be misleading. return null; } $userenrol = $this->retrieve('user_enrolments', $sampleid); $key = $course->get_id(); // Number of competencies in the course. $ccs = $this->get_num_competencies_in_course($key); // Number of proficient competencies in the same course for the user. $ucs = \core_competency\api::count_proficient_competencies_in_course_for_user($key, $userenrol->userid); // If they are the equals, the user achieved all the competencies assigned to the course. if ($ccs == $ucs) { return 0; } return 1; } } target/course_completion.php000064400000007415152266532100012305 0ustar00. /** * Course completion target. * * @package core_course * @copyright 2019 Victor Deniz * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_course\analytics\target; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/course/lib.php'); require_once($CFG->dirroot . '/lib/completionlib.php'); require_once($CFG->dirroot . '/completion/completion_completion.php'); /** * Course completion target. * * @package core_course * @copyright 2019 Victor Deniz * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class course_completion extends course_enrolments { /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('target:coursecompletion', 'course'); } /** * Returns descriptions for each of the values the target calculation can return. * * @return string[] */ protected static function classes_description() { return array( get_string('targetlabelstudentcompletionno', 'course'), get_string('targetlabelstudentcompletionyes', 'course') ); } /** * Discards courses that are not yet ready to be used for training or prediction. * * @param \core_analytics\analysable $course * @param bool $fortraining * @return true|string */ public function is_valid_analysable(\core_analytics\analysable $course, $fortraining = true) { $isvalid = parent::is_valid_analysable($course, $fortraining); if (is_string($isvalid)) { return $isvalid; } // Not a valid target if completion is not enabled or there are not completion criteria defined. $completion = new \completion_info($course->get_course_data()); if (!$completion->is_enabled() || !$completion->has_criteria()) { return get_string('completionnotenabledforcourse', 'completion'); } return true; } /** * Course completion sets the target value. * * @param int $sampleid * @param \core_analytics\analysable $course * @param int $starttime * @param int $endtime * @return float|null 0 -> course not completed, 1 -> course completed */ protected function calculate_sample($sampleid, \core_analytics\analysable $course, $starttime = false, $endtime = false) { if (!$this->enrolment_active_during_analysis_time($sampleid, $starttime, $endtime)) { // We should not use this sample as the analysis results could be misleading. return null; } $userenrol = $this->retrieve('user_enrolments', $sampleid); // We use completion as a success metric. $ccompletion = new \completion_completion(array('userid' => $userenrol->userid, 'course' => $course->get_id())); if ($ccompletion->is_complete()) { return 0; } else { return 1; } } } indicators_test.php000064400000033265152266550710010475 0ustar00. namespace core_course\analytics; use core_analytics\tests\mlbackend_helper_trait; defined('MOODLE_INTERNAL') || die(); global $CFG; require_once(__DIR__ . '/../../../lib/completionlib.php'); require_once(__DIR__ . '/../../../completion/criteria/completion_criteria_self.php'); require_once(__DIR__ . '/../../../analytics/tests/fixtures/test_target_course_users.php'); /** * Unit tests for core_course indicators. * * @package core_course * @category test * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class indicators_test extends \advanced_testcase { use mlbackend_helper_trait; /** * test_no_teacher * * @return void */ public function test_no_teacher(): void { global $DB; $this->resetAfterTest(true); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $coursecontext1 = \context_course::instance($course1->id); $coursecontext2 = \context_course::instance($course2->id); $user = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($user->id, $course1->id, 'student'); $this->getDataGenerator()->enrol_user($user->id, $course2->id, 'teacher'); $indicator = new \core_course\analytics\indicator\no_teacher(); $sampleids = array($course1->id => $course1->id, $course2->id => $course2->id); $data = array( $course1->id => array( 'context' => $coursecontext1, 'course' => $course1, ), $course2->id => array( 'context' => $coursecontext2, 'course' => $course2, )); $indicator->add_sample_data($data); list($values, $ignored) = $indicator->calculate($sampleids, 'course'); $this->assertEquals($indicator::get_min_value(), $values[$course1->id][0]); $this->assertEquals($indicator::get_max_value(), $values[$course2->id][0]); } /** * test_completion_enabled * * @return void */ public function test_completion_enabled(): void { global $DB; $this->resetAfterTest(true); $course1 = $this->getDataGenerator()->create_course(array('enablecompletion' => 0)); $course2 = $this->getDataGenerator()->create_course(array('enablecompletion' => 1)); $course3 = $this->getDataGenerator()->create_course(array('enablecompletion' => 1)); // Criteria only for the last one. $criteriadata = new \stdClass(); $criteriadata->id = $course3->id; $criteriadata->criteria_self = 1; $criterion = new \completion_criteria_self(); $criterion->update_config($criteriadata); $indicator = new \core_course\analytics\indicator\completion_enabled(); $sampleids = array($course1->id => $course1->id, $course2->id => $course2->id, $course3->id => $course3->id); $data = array( $course1->id => array( 'course' => $course1, ), $course2->id => array( 'course' => $course2, ), $course3->id => array( 'course' => $course3, )); $indicator->add_sample_data($data); // Calculate using course samples. list($values, $ignored) = $indicator->calculate($sampleids, 'course'); $this->assertEquals($indicator::get_min_value(), $values[$course1->id][0]); $this->assertEquals($indicator::get_min_value(), $values[$course2->id][0]); $this->assertEquals($indicator::get_max_value(), $values[$course3->id][0]); // Calculate using course_modules samples. $indicator->clear_sample_data(); $data1 = $this->getDataGenerator()->create_module('data', array('course' => $course3->id), array('completion' => 0)); $data2 = $this->getDataGenerator()->create_module('data', array('course' => $course3->id), array('completion' => 1)); $sampleids = array($data1->cmid => $data1->cmid, $data2->cmid => $data2->cmid); $cm1 = $DB->get_record('course_modules', array('id' => $data1->cmid)); $cm2 = $DB->get_record('course_modules', array('id' => $data2->cmid)); $data = array( $cm1->id => array( 'course' => $course3, 'course_modules' => $cm1, ), $cm2->id => array( 'course' => $course3, 'course_modules' => $cm2, )); $indicator->add_sample_data($data); list($values, $ignored) = $indicator->calculate($sampleids, 'course_modules'); $this->assertEquals($indicator::get_min_value(), $values[$cm1->id][0]); $this->assertEquals($indicator::get_max_value(), $values[$cm2->id][0]); } /** * test_potential_cognitive * * @return void */ public function test_potential_cognitive(): void { global $DB; $this->resetAfterTest(true); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $page = $this->getDataGenerator()->create_module('page', array('course' => $course2->id)); $course3 = $this->getDataGenerator()->create_course(); $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course3->id)); $assign = $this->getDataGenerator()->create_module('assign', array('course' => $course3->id)); $course4 = $this->getDataGenerator()->create_course(); $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course4->id)); $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course4->id)); $indicator = new \core_course\analytics\indicator\potential_cognitive_depth(); $sampleids = array($course1->id => $course1->id, $course2->id => $course2->id, $course3->id => $course3->id, $course4->id => $course4->id); $data = array( $course1->id => array( 'course' => $course1, ), $course2->id => array( 'course' => $course2, ), $course3->id => array( 'course' => $course3, ), $course4->id => array( 'course' => $course4, )); $indicator->add_sample_data($data); list($values, $ignored) = $indicator->calculate($sampleids, 'course'); $this->assertEquals($indicator::get_min_value(), $values[$course1->id][0]); // General explanation about the points, the max level is 5 so level 1 is -1, level 2 is -0.5, level 3 is 0, // level 4 is 0.5 and level 5 is 1. // Page cognitive is level 1 (the lower one). $this->assertEquals($indicator::get_min_value(), $values[$course2->id][0]); // The maximum cognitive depth level is 5, assign level is 5 therefore the potential cognitive depth is the max. $this->assertEquals($indicator::get_max_value(), $values[$course3->id][0]); // Forum level is 4. $this->assertEquals(0.5, $values[$course4->id][0]); // Calculate using course_modules samples. $course5 = $this->getDataGenerator()->create_course(); $assign = $this->getDataGenerator()->create_module('assign', array('course' => $course5->id)); $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course5->id)); $sampleids = array($assign->cmid => $assign->cmid, $forum->cmid => $forum->cmid); $cm1 = $DB->get_record('course_modules', array('id' => $assign->cmid)); $cm2 = $DB->get_record('course_modules', array('id' => $forum->cmid)); $data = array( $cm1->id => array( 'course' => $course5, 'course_modules' => $cm1, ), $cm2->id => array( 'course' => $course5, 'course_modules' => $cm2, )); $indicator->clear_sample_data(); $indicator->add_sample_data($data); list($values, $ignored) = $indicator->calculate($sampleids, 'course_modules'); // Assign level is 5, the maximum level. $this->assertEquals($indicator::get_max_value(), $values[$cm1->id][0]); // Forum level is 4. $this->assertEquals(0.5, $values[$cm2->id][0]); } /** * test_potential_social * * @return void */ public function test_potential_social(): void { global $DB; $this->resetAfterTest(true); $course1 = $this->getDataGenerator()->create_course(); $course2 = $this->getDataGenerator()->create_course(); $page = $this->getDataGenerator()->create_module('page', array('course' => $course2->id)); $course3 = $this->getDataGenerator()->create_course(); $forum = $this->getDataGenerator()->create_module('forum', array('course' => $course3->id)); $assign = $this->getDataGenerator()->create_module('assign', array('course' => $course3->id)); $course4 = $this->getDataGenerator()->create_course(); $page = $this->getDataGenerator()->create_module('page', array('course' => $course4->id)); $assign = $this->getDataGenerator()->create_module('assign', array('course' => $course4->id)); $indicator = new \core_course\analytics\indicator\potential_social_breadth(); $sampleids = array($course1->id => $course1->id, $course2->id => $course2->id, $course3->id => $course3->id, $course4->id => $course4->id); $data = array( $course1->id => array( 'course' => $course1, ), $course2->id => array( 'course' => $course2, ), $course3->id => array( 'course' => $course3, ), $course4->id => array( 'course' => $course4, )); $indicator->add_sample_data($data); list($values, $ignored) = $indicator->calculate($sampleids, 'course'); $this->assertEquals($indicator::get_min_value(), $values[$course1->id][0]); // General explanation about the points, the max level is 2 so level 1 is -1, level 2 is 1. // Page social is level 1 (the lower level). $this->assertEquals($indicator::get_min_value(), $values[$course2->id][0]); // Forum is level 2 and assign is level 2. $this->assertEquals($indicator::get_max_value(), $values[$course3->id][0]); // Page is level 1 and assign is level 2, so potential level is the max one. $this->assertEquals($indicator::get_max_value(), $values[$course4->id][0]); // Calculate using course_modules samples. $course5 = $this->getDataGenerator()->create_course(); $assign = $this->getDataGenerator()->create_module('assign', array('course' => $course5->id)); $page = $this->getDataGenerator()->create_module('page', array('course' => $course5->id)); $sampleids = array($assign->cmid => $assign->cmid, $page->cmid => $page->cmid); $cm1 = $DB->get_record('course_modules', array('id' => $assign->cmid)); $cm2 = $DB->get_record('course_modules', array('id' => $page->cmid)); $data = array( $cm1->id => array( 'course' => $course5, 'course_modules' => $cm1, ), $cm2->id => array( 'course' => $course5, 'course_modules' => $cm2, )); $indicator->clear_sample_data(); $indicator->add_sample_data($data); list($values, $ignored) = $indicator->calculate($sampleids, 'course_modules'); // Assign social is level 2 (the max level). $this->assertEquals($indicator::get_max_value(), $values[$cm1->id][0]); // Page social is level 1 (the lower level). $this->assertEquals($indicator::get_min_value(), $values[$cm2->id][0]); } /** * test_activities_due * * @return void */ public function test_activities_due(): void { global $DB; if (!self::is_mlbackend_python_configured()) { $this->markTestSkipped('mlbackend_python is not configured.'); } $this->resetAfterTest(true); $this->setAdminuser(); $course1 = $this->getDataGenerator()->create_course(); $user1 = $this->getDataGenerator()->create_user(); $this->getDataGenerator()->enrol_user($user1->id, $course1->id, 'student'); $target = \core_analytics\manager::get_target('test_target_course_users'); $indicators = array('\core_course\analytics\indicator\activities_due'); foreach ($indicators as $key => $indicator) { $indicators[$key] = \core_analytics\manager::get_indicator($indicator); } $model = \core_analytics\model::create($target, $indicators); $model->enable('\core\analytics\time_splitting\single_range'); $model->train(); } } home/smkb8269/.cpanel/nvdata/analytics000064400000000003152266621220013465 0ustar00offindicator/cognitive_depth.php000064400000004311152267106170012412 0ustar00. /** * Cognitive depth indicator - quiz. * * @package mod_quiz * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\analytics\indicator; defined('MOODLE_INTERNAL') || die(); /** * Cognitive depth indicator - quiz. * * @package mod_quiz * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class cognitive_depth extends activity_base { /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:cognitivedepth', 'mod_quiz'); } public function get_indicator_type() { return self::INDICATOR_COGNITIVE; } public function get_cognitive_depth_level(\cm_info $cm) { return self::COGNITIVE_LEVEL_5; } /** * feedback_submitted_events * * @return string[] */ protected function feedback_submitted_events() { return ['\mod_quiz\event\attempt_submitted']; } /** * feedback_replied * * @param \cm_info $cm * @param int $contextid * @param int $userid * @param int $after * @return bool */ protected function feedback_replied(\cm_info $cm, $contextid, $userid, $after = false) { // No level 4. return false; } } indicator/activity_base.php000064400000003565152267106170012077 0ustar00. /** * Activity base class. * * @package mod_quiz * @copyright 2017 onwards Ankit Agarwal * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\analytics\indicator; defined('MOODLE_INTERNAL') || die(); /** * Activity base class. * * @package mod_quiz * @copyright 2017 onwards Ankit Agarwal * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class activity_base extends \core_analytics\local\indicator\community_of_inquiry_activity { /** * feedback_check_grades * * @return bool */ protected function feedback_check_grades() { // We need the grade to be released to the student to consider that feedback has been provided. return true; } /** * feedback_viewed_events * * @return string[] */ protected function feedback_viewed_events() { return ['\mod_quiz\event\course_module_viewed']; } /** * Returns the name of the field that controls activity availability. * * @return null|string */ protected function get_timeclose_field() { return 'timeclose'; } } indicator/social_breadth.php000064400000003275152267106170012212 0ustar00. /** * Social breadth indicator - quiz. * * @package mod_quiz * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\analytics\indicator; defined('MOODLE_INTERNAL') || die(); /** * Social breadth indicator - quiz. * * @package mod_quiz * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class social_breadth extends activity_base { /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:socialbreadth', 'mod_quiz'); } public function get_indicator_type() { return self::INDICATOR_SOCIAL; } public function get_social_breadth_level(\cm_info $cm) { return self::SOCIAL_LEVEL_2; } } time_splitting/no_splitting.php000064400000004372152267651410013040 0ustar00. /** * No time splitting method. * * Used when time is not a factor to consider into the equation. * * @package core * @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\time_splitting; defined('MOODLE_INTERNAL') || die(); /** * No time splitting method. * * Used when time is not a factor to consider into the equation. * * @package core * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class no_splitting extends \core_analytics\local\time_splitting\base { /** * 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 */ public static function get_name(): \lang_string { return new \lang_string('timesplitting:nosplitting'); } /** * ready_to_predict * * @param array $range * @return true */ public function ready_to_predict($range) { return true; } /** * define_ranges * * @return array */ protected function define_ranges() { return [ [ 'start' => 0, 'end' => \core_analytics\analysable::MAX_TIME, // Time is ignored as we overwrite ready_to_predict. 'time' => 0 ] ]; } } time_splitting/quarters_accum.php000064400000003516152267651410013344 0ustar00. /** * Range processor splitting the course in quarters and accumulating data. * * @package core * @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\time_splitting; defined('MOODLE_INTERNAL') || die(); /** * Range processor splitting the course in quarters and accumulating data. * * @package core * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quarters_accum extends \core_analytics\local\time_splitting\accumulative_parts { /** * Returns a lang_string object representing the name for the time spliting method. * * Used as column identificator. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('timesplitting:quartersaccum'); } /** * 4 parts. * * @return int */ protected function get_number_parts() { return 4; } } time_splitting/upcoming_3_days.php000064400000003426152267651410013411 0ustar00. /** * Time splitting method that generates insights every three days and calculates indicators using upcoming dates. * * @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\time_splitting; defined('MOODLE_INTERNAL') || die(); /** * Time splitting method that generates insights every three days and calculates indicators using upcoming dates. * * @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 upcoming_3_days extends \core_analytics\local\time_splitting\upcoming_periodic { /** * The time splitting method name. * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('timesplitting:upcoming3days'); } /** * Once every three days. * @return \DateInterval */ public function periodicity() { return new \DateInterval('P3D'); } }time_splitting/deciles_accum.php000064400000003521152267651410013102 0ustar00. /** * Range processor splitting the course in ten parts and accumulating data. * * @package core * @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\time_splitting; defined('MOODLE_INTERNAL') || die(); /** * Range processor splitting the course in ten parts and accumulating data. * * @package core * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class deciles_accum extends \core_analytics\local\time_splitting\accumulative_parts { /** * 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 */ public static function get_name(): \lang_string { return new \lang_string('timesplitting:decilesaccum'); } /** * 10 parts. * * @return int */ protected function get_number_parts() { return 10; } } time_splitting/one_month_after_start.php000064400000003645152267651410014715 0ustar00. /** * Time splitting method that generates predictions one month 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\time_splitting; defined('MOODLE_INTERNAL') || die(); /** * Time splitting method that generates predictions one month 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 */ class one_month_after_start extends \core_analytics\local\time_splitting\after_start { /** * The time splitting method name. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('timesplitting:onemonthafterstart'); } /** * The period we should wait until we generate predictions for this. * * @param \core_analytics\analysable $analysable Not used in this implementation. * @return \DateInterval */ protected function wait_period(\core_analytics\analysable $analysable) { return new \DateInterval('P1M'); } } time_splitting/past_month.php000064400000003245152267651410012501 0ustar00. /** * Time splitting method that generates monthly predictions. * * @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\time_splitting; defined('MOODLE_INTERNAL') || die(); /** * Time splitting method that generates monthly predictions. * * @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 past_month extends \core_analytics\local\time_splitting\past_periodic { /** * The time splitting method name. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('timesplitting:pastmonth'); } /** * Once a month. * * @return \DateInterval */ public function periodicity() { return new \DateInterval('P1M'); } } time_splitting/quarters.php000064400000003353152267651410012173 0ustar00. /** * Quarters time splitting method. * * @package core * @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\time_splitting; defined('MOODLE_INTERNAL') || die(); /** * Quarters time splitting method. * * @package core * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quarters extends \core_analytics\local\time_splitting\equal_parts { /** * Returns a lang_string object representing the name for the time spliting method. * * Used as column identificator. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('timesplitting:quarters'); } /** * 4 parts * * @return int */ protected function get_number_parts() { return 4; } } time_splitting/one_week_after_start.php000064400000003641152267651410014517 0ustar00. /** * Time splitting method that generates predictions one week 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\time_splitting; defined('MOODLE_INTERNAL') || die(); /** * Time splitting method that generates predictions one week 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 */ class one_week_after_start extends \core_analytics\local\time_splitting\after_start { /** * The time splitting method name. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('timesplitting:oneweekafterstart'); } /** * The period we should wait until we generate predictions for this. * * @param \core_analytics\analysable $analysable Not used in this implementation. * @return \DateInterval */ protected function wait_period(\core_analytics\analysable $analysable) { return new \DateInterval('P1W'); } } time_splitting/past_3_days.php000064400000003265152267651410012540 0ustar00. /** * Time splitting method that generates predictions every 3 days. * * @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\time_splitting; defined('MOODLE_INTERNAL') || die(); /** * Time splitting method that generates predictions every 3 days. * * @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 past_3_days extends \core_analytics\local\time_splitting\past_periodic { /** * The time splitting method name. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('timesplitting:past3days'); } /** * Once every 3 days. * * @return \DateInterval */ public function periodicity() { return new \DateInterval('P3D'); } } time_splitting/ten_percent_after_start.php000064400000005215152267651410015230 0ustar00. /** * Time splitting method that generates predictions 3 days 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\time_splitting; defined('MOODLE_INTERNAL') || die(); /** * Time splitting method that generates predictions 3 days 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 */ class ten_percent_after_start extends \core_analytics\local\time_splitting\after_start { /** * The time splitting method name. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('timesplitting:tenpercentafterstart'); } /** * Extended as we require and end date here. * * @param \core_analytics\analysable $analysable * @return bool */ public function is_valid_analysable(\core_analytics\analysable $analysable) { // We require an end date to calculate the 10%. if (!$analysable->get_end()) { return false; } return parent::is_valid_analysable($analysable); } /** * The period we should wait until we generate predictions for this. * * @throws \coding_exception * @param \core_analytics\analysable $analysable * @return \DateInterval */ protected function wait_period(\core_analytics\analysable $analysable) { if (!$analysable->get_end() || !$analysable->get_start()) { throw new \coding_exception('Analysables with no start or end should be discarded in is_valid_analysable.'); } $diff = $analysable->get_end() - $analysable->get_start(); // A 10% of $diff. return new \DateInterval('PT' . intval($diff / 10) . 'S'); } } time_splitting/single_range.php000064400000004136152267651410012762 0ustar00. /** * Single time splitting method. * * @package core * @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\time_splitting; defined('MOODLE_INTERNAL') || die(); /** * Single time splitting method. * * @package core * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class single_range extends \core_analytics\local\time_splitting\base implements \core_analytics\local\time_splitting\before_now { /** * Returns a lang_string object representing the name for the time spliting method. * * Used as column identificator. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('timesplitting:singlerange'); } /** * One single range covering all analysable duration. * * @return array */ protected function define_ranges() { // Key 'time' == 0 because we want it to start predicting from the beginning. return [ [ 'start' => $this->analysable->get_start(), 'end' => $this->analysable->get_end(), 'time' => 0 ] ]; } } time_splitting/past_week.php000064400000003233152267651410012304 0ustar00. /** * Time splitting method that generates weekly predictions. * * @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\time_splitting; defined('MOODLE_INTERNAL') || die(); /** * Time splitting method that generates weekly predictions. * * @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 past_week extends \core_analytics\local\time_splitting\past_periodic { /** * The time splitting method name. * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('timesplitting:pastweek'); } /** * Once per week. * * @return \DateInterval */ public function periodicity() { return new \DateInterval('P1W'); } } time_splitting/deciles.php000064400000003355152267651410011737 0ustar00. /** * 10 parts time splitting method. * * @package core * @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\time_splitting; defined('MOODLE_INTERNAL') || die(); /** * 10 parts time splitting method. * * @package core * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class deciles extends \core_analytics\local\time_splitting\equal_parts { /** * 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 */ public static function get_name(): \lang_string { return new \lang_string('timesplitting:deciles'); } /** * 10 parts. * * @return int */ protected function get_number_parts() { return 10; } } time_splitting/upcoming_fortnight.php000064400000003425152267651410014232 0ustar00. /** * Time splitting method that generates insights every fortnight and calculates indicators using upcoming dates. * * @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\time_splitting; defined('MOODLE_INTERNAL') || die(); /** * Time splitting method that generates insights every fortnight and calculates indicators using upcoming dates. * * @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 upcoming_fortnight extends \core_analytics\local\time_splitting\upcoming_periodic { /** * The time splitting method name. * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('timesplitting:upcomingfortnight'); } /** * Every two weeks. * @return \DateInterval */ public function periodicity() { return new \DateInterval('P2W'); } }time_splitting/upcoming_week.php000064400000003240152267651410013154 0ustar00. /** * Time splitting method that generates weekly predictions. * * @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\time_splitting; defined('MOODLE_INTERNAL') || die(); /** * Time splitting method that generates weekly predictions. * * @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 upcoming_week extends \core_analytics\local\time_splitting\upcoming_periodic { /** * The time splitting method name. * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('timesplitting:upcomingweek'); } /** * Once per week. * @return \DateInterval */ public function periodicity() { return new \DateInterval('P1W'); } } indicator/any_write_action_in_course.php000064400000006220152267651410014646 0ustar00. /** * Write actions in a course indicator. * * @package core * @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\indicator; defined('MOODLE_INTERNAL') || die(); /** * Write actions in a course indicator. * * @package core * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class any_write_action_in_course extends \core_analytics\local\indicator\binary { /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:anywriteincourse'); } /** * required_sample_data * * @return string[] */ public static function required_sample_data() { // User is not required, calculate_sample can handle its absence. return array('course'); } /** * calculate_sample * * @param int $sampleid * @param string $sampleorigin * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, $sampleorigin, $starttime = false, $endtime = false) { global $DB; if (!$logstore = \core_analytics\manager::get_analytics_logstore()) { throw new \coding_exception('No available log stores'); } // Filter by context to use the logstore_standard_log db table index. $course = $this->retrieve('course', $sampleid); $select = "courseid = :courseid AND anonymous = :anonymous AND (crud = 'c' OR crud = 'u')"; $params = array('courseid' => $course->id, 'anonymous' => '0'); if ($user = $this->retrieve('user', $sampleid)) { $select .= " AND userid = :userid"; $params['userid'] = $user->id; } if ($starttime) { $select .= " AND timecreated > :starttime"; $params['starttime'] = $starttime; } if ($endtime) { $select .= " AND timecreated <= :endtime"; $params['endtime'] = $endtime; } $nlogs = $logstore->get_events_select_count($select, $params); if ($nlogs) { return self::get_max_value(); } else { return self::get_min_value(); } } } indicator/any_course_access.php000064400000011554152267651410012740 0ustar00. /** * Any access indicator. * * @package core * @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\indicator; defined('MOODLE_INTERNAL') || die(); /** * Any access indicator. * * @package core * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class any_course_access extends \core_analytics\local\indicator\binary { /** @var array user last access. */ protected $lastaccesses = []; /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:anycourseaccess'); } /** * required_sample_data * * @return string[] */ public static function required_sample_data() { return array('course', 'user'); } /** * Store userid => timeaccess relation if the provided analysable is a course. * * @param \core_analytics\analysable $analysable * @return null */ public function fill_per_analysable_caches(\core_analytics\analysable $analysable) { global $DB; if ($analysable instanceof \core_analytics\course) { // Indexed by userid (there is a UNIQUE KEY at DB level). $this->lastaccesses = $DB->get_records('user_lastaccess', ['courseid' => $analysable->get_id()], '', 'userid, timeaccess'); } } /** * calculate_sample * * @param int $sampleid * @param string $sampleorigin * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, $sampleorigin, $starttime = false, $endtime = false) { $course = $this->retrieve('course', $sampleid); $user = $this->retrieve('user', $sampleid); // We first try using user_lastaccess as it is much faster than the log table. if (empty($this->lastaccesses[$user->id]->timeaccess)) { // The user never accessed. return self::get_min_value(); } else if (!$starttime && !$endtime) { // No time restrictions, so all good as long as there is a record. return self::get_max_value(); } else if ($starttime && $this->lastaccesses[$user->id]->timeaccess < $starttime) { // The last access is prior to $starttime. return self::get_min_value(); } else if ($endtime && $this->lastaccesses[$user->id]->timeaccess < $endtime) { // The last access is before the $endtime. return self::get_max_value(); } else if ($starttime && !$endtime && $starttime <= $this->lastaccesses[$user->id]->timeaccess) { // No end time, so max value as long as the last access is after $starttime. return self::get_max_value(); } // If the last access is after $endtime we can not know for sure if the user accessed or not // between $starttime and $endtime, we need to check the logs table in this case. Note that // it is unlikely that we will reach this point as this indicator will be used in models whose // dates are in the past. if (!$logstore = \core_analytics\manager::get_analytics_logstore()) { throw new \coding_exception('No available log stores'); } // Filter by context to use the logstore_standard_log db table index. $select = "userid = :userid AND courseid = :courseid"; $params = ['courseid' => $course->id, 'userid' => $user->id]; if ($starttime) { $select .= " AND timecreated > :starttime"; $params['starttime'] = $starttime; } if ($endtime) { $select .= " AND timecreated <= :endtime"; $params['endtime'] = $endtime; } $nlogs = $logstore->get_events_select_count($select, $params); if ($nlogs) { return self::get_max_value(); } else { return self::get_min_value(); } } } indicator/any_access_before_start.php000064400000005776152267651410014130 0ustar00. /** * Any access before the official start of the course. * * @package core * @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\indicator; defined('MOODLE_INTERNAL') || die(); /** * Any access before the official start of the course. * * @package core * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class any_access_before_start extends \core_analytics\local\indicator\binary { /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:accessesbeforestart'); } /** * required_sample_data * * @return string[] */ public static function required_sample_data() { return array('user', 'course', 'context'); } /** * calculate_sample * * @param int $sampleid * @param string $samplesorigin * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, $samplesorigin, $starttime = false, $endtime = false) { global $DB; $user = $this->retrieve('user', $sampleid); $course = \core_analytics\course::instance($this->retrieve('course', $sampleid)); if (!$logstore = \core_analytics\manager::get_analytics_logstore()) { throw new \coding_exception('No available log stores'); } // Filter by context to use the logstore_standard_log db table index. $context = $this->retrieve('context', $sampleid); $select = "userid = :userid AND contextlevel = :contextlevel AND contextinstanceid = :contextinstanceid AND " . "timecreated < :start"; $params = array('userid' => $user->id, 'contextlevel' => $context->contextlevel, 'contextinstanceid' => $context->instanceid, 'start' => $course->get_start()); $nlogs = $logstore->get_events_select_count($select, $params); if ($nlogs) { return self::get_max_value(); } else { return self::get_min_value(); } } } indicator/any_access_after_end.php000064400000005600152267651410013362 0ustar00. /** * Any access after the official end of the course. * * @package core * @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\indicator; defined('MOODLE_INTERNAL') || die(); /** * Any access after the official end of the course. * * @package core * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class any_access_after_end extends \core_analytics\local\indicator\binary { /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:accessesafterend'); } /** * required_sample_data * * @return string[] */ public static function required_sample_data() { return array('user', 'course', 'context'); } /** * calculate_sample * * @param int $sampleid * @param string $samplesorigin * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, $samplesorigin, $starttime = false, $endtime = false) { global $DB; $user = $this->retrieve('user', $sampleid); $course = \core_analytics\course::instance($this->retrieve('course', $sampleid)); // Filter by context to use the db table index. $context = $this->retrieve('context', $sampleid); $select = "userid = :userid AND contextlevel = :contextlevel AND contextinstanceid = :contextinstanceid AND " . "timecreated > :end"; $params = array('userid' => $user->id, 'contextlevel' => $context->contextlevel, 'contextinstanceid' => $context->instanceid, 'end' => $course->get_end()); $logstore = \core_analytics\manager::get_analytics_logstore(); $nlogs = $logstore->get_events_select_count($select, $params); if ($nlogs) { return self::get_max_value(); } else { return self::get_min_value(); } } } indicator/any_write_action.php000064400000006426152267651410012610 0ustar00. /** * Write actions indicator. * * @package core * @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\indicator; defined('MOODLE_INTERNAL') || die(); /** * Write actions indicator. * * @package core * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class any_write_action extends \core_analytics\local\indicator\binary { /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:anywrite'); } /** * required_sample_data * * @return string[] */ public static function required_sample_data() { // User is not required, calculate_sample can handle its absence. return array('context'); } /** * calculate_sample * * @param int $sampleid * @param string $sampleorigin * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, $sampleorigin, $starttime = false, $endtime = false) { global $DB; $select = ''; $params = array(); if ($user = $this->retrieve('user', $sampleid)) { $select .= "userid = :userid AND "; $params = $params + array('userid' => $user->id); } if (!$logstore = \core_analytics\manager::get_analytics_logstore()) { throw new \coding_exception('No available log stores'); } // Filter by context to use the logstore_standard_log db table index. $context = $this->retrieve('context', $sampleid); $select .= "contextlevel = :contextlevel AND contextinstanceid = :contextinstanceid AND " . "(crud = 'c' OR crud = 'u')"; $params = $params + array('contextlevel' => $context->contextlevel, 'contextinstanceid' => $context->instanceid); if ($starttime) { $select .= " AND timecreated > :starttime"; $params['starttime'] = $starttime; } if ($endtime) { $select .= " AND timecreated <= :endtime"; $params['endtime'] = $endtime; } $nlogs = $logstore->get_events_select_count($select, $params); if ($nlogs) { return self::get_max_value(); } else { return self::get_min_value(); } } } indicator/read_actions.php000064400000007627152267651410011711 0ustar00. /** * Read actions indicator. * * @package core * @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\indicator; defined('MOODLE_INTERNAL') || die(); /** * Read actions indicator. * * @package core * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class read_actions extends \core_analytics\local\indicator\linear { /** * Returns the name. * * If there is a corresponding '_help' string this will be shown as well. * * @return \lang_string */ public static function get_name(): \lang_string { return new \lang_string('indicator:readactions'); } /** * required_sample_data * * @return string[] */ public static function required_sample_data() { // User is not required, calculate_sample can handle its absence. return array('context'); } /** * calculate_sample * * @param int $sampleid * @param string $sampleorigin * @param int $starttime * @param int $endtime * @return float */ protected function calculate_sample($sampleid, $sampleorigin, $starttime = false, $endtime = false) { if (!$starttime || !$endtime) { return null; } $select = ''; $params = array(); if ($user = $this->retrieve('user', $sampleid)) { $select .= "userid = :userid AND "; $params = $params + array('userid' => $user->id); } if (!$logstore = \core_analytics\manager::get_analytics_logstore()) { throw new \coding_exception('No available log stores'); } // Filter by context to use the logstore_standard_log db table index. $context = $this->retrieve('context', $sampleid); $select .= "contextlevel = :contextlevel AND contextinstanceid = :contextinstanceid AND " . "crud = 'r' AND timecreated > :starttime AND timecreated <= :endtime"; $params = $params + array('contextlevel' => $context->contextlevel, 'contextinstanceid' => $context->instanceid, 'starttime' => $starttime, 'endtime' => $endtime); $nrecords = $logstore->get_events_select_count($select, $params); // We define a list of ranges to fit $nrecords into it // # Done absolutely nothing // # Not much really, just accessing the course once a week // # More than just accessing the course, some interaction // # Significant contribution, will depend on the course anyway. // We need to adapt the limits to the time range duration. $nweeks = $this->get_time_range_weeks_number($starttime, $endtime); // Careful with this, depends on the course. $limit = $nweeks * 3 * 10; $ranges = array( array('eq', 0), // 1 course access per week (3 records are easily generated). array('le', $nweeks * 3), // 3 course accesses per week doing some stuff. array('le', $limit), array('gt', $limit) ); return $this->classify_value($nrecords, $ranges); } } analyser/courses.php000064400000010100152267651410010557 0ustar00. /** * Courses analyser working at course level (insights for the course teachers). * * @package core * @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\analyser; defined('MOODLE_INTERNAL') || die(); /** * Courses analyser working at course level (insights for the course teachers). * * @package core * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class courses extends \core_analytics\local\analyser\by_course { /** * Samples origin is course table. * * @return string */ public function get_samples_origin() { return 'course'; } /** * Just one sample per analysable. * * @return bool */ public static function one_sample_per_analysable() { return true; } /** * Returns the analysable of a sample * * @param int $sampleid * @return \core_analytics\analysable */ public function get_sample_analysable($sampleid) { return \core_analytics\course::instance($sampleid); } /** * This provides samples' course and context. * * @return string[] */ protected function provided_sample_data() { return array('course', 'context'); } /** * Returns the context of a sample. * * @param int $sampleid * @return \context */ public function sample_access_context($sampleid) { return \context_course::instance($sampleid); } /** * This will return just one course as we analyse 'by_course'. * * @param \core_analytics\analysable $course * @return array */ public function get_all_samples(\core_analytics\analysable $course) { $context = \context_course::instance($course->get_id()); // Just 1 sample per analysable. return array( array($course->get_id() => $course->get_id()), array($course->get_id() => array('course' => $course->get_course_data(), 'context' => $context)) ); } /** * Returns samples data from sample ids. * * @param int[] $sampleids * @return array */ public function get_samples($sampleids) { global $DB; list($sql, $params) = $DB->get_in_or_equal($sampleids, SQL_PARAMS_NAMED); $courses = $DB->get_records_select('course', "id $sql", $params); $courseids = array_keys($courses); $sampleids = array_combine($courseids, $courseids); $courses = array_map(function($course) { return array('course' => $course, 'context' => \context_course::instance($course->id)); }, $courses); // No related data attached. return array($sampleids, $courses); } /** * Returns the sample description * * @param int $sampleid * @param int $contextid * @param array $sampledata * @return array array(string, \renderable) */ public function sample_description($sampleid, $contextid, $sampledata) { $description = format_string( get_course_display_name_for_list($sampledata['course']), true, array('context' => $sampledata['context'])); $courseimage = new \pix_icon('i/course', get_string('course')); return array($description, $courseimage); } } analyser/site_courses.php000064400000010245152267651410011615 0ustar00. /** * Site courses analyser working at system level (insights for the site admin). * * @package core * @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\analyser; defined('MOODLE_INTERNAL') || die(); /** * Site courses analyser working at system level (insights for the site admin). * * @package core * @copyright 2017 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class site_courses extends \core_analytics\local\analyser\sitewide { /** * Samples origin is course table. * * @return string */ public function get_samples_origin() { return 'course'; } /** * Returns the sample analysable * * @param int $sampleid * @return \core_analytics\analysable */ public function get_sample_analysable($sampleid) { return new \core_analytics\site(); } /** * Data this analyer samples provide. * * @return string[] */ protected function provided_sample_data() { return array('course', 'context'); } /** * Returns the sample context. * * @param int $sampleid * @return \context */ public function sample_access_context($sampleid) { return \context_system::instance(); } /** * Returns all site courses. * * @param \core_analytics\analysable $site * @return array */ public function get_all_samples(\core_analytics\analysable $site) { global $DB; // Getting courses from DB instead of from the site as these samples // will be stored in memory and we just want the id. $select = 'id != 1'; $courses = get_courses('all', 'c.sortorder ASC'); unset($courses[SITEID]); $courseids = array_keys($courses); $sampleids = array_combine($courseids, $courseids); $courses = array_map(function($course) { return array('course' => $course, 'context' => \context_course::instance($course->id)); }, $courses); // No related data attached. return array($sampleids, $courses); } /** * Return all complete samples data from sample ids. * * @param int[] $sampleids * @return array */ public function get_samples($sampleids) { global $DB; list($sql, $params) = $DB->get_in_or_equal($sampleids, SQL_PARAMS_NAMED); $courses = $DB->get_records_select('course', "id $sql", $params); $courseids = array_keys($courses); $sampleids = array_combine($courseids, $courseids); $courses = array_map(function($course) { return array('course' => $course, 'context' => \context_course::instance($course->id)); }, $courses); // No related data attached. return array($sampleids, $courses); } /** * Returns the description of a sample. * * @param int $sampleid * @param int $contextid * @param array $sampledata * @return array array(string, \renderable) */ public function sample_description($sampleid, $contextid, $sampledata) { $description = format_string( get_course_display_name_for_list($sampledata['course']), true, array('context' => $sampledata['context'])); $courseimage = new \pix_icon('i/course', get_string('course')); return array($description, $courseimage); } } analyser/student_enrolments.php000064400000020255152267651410013044 0ustar00. /** * Student enrolments analyser. * * @package core * @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\analyser; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/lib/enrollib.php'); /** * Student enrolments analyser. * * It does return all student enrolments including the suspended ones. * * @package core * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class student_enrolments extends \core_analytics\local\analyser\by_course { /** * @var array Cache for user_enrolment id - course id relation. */ protected $samplecourses = array(); /** * Defines the origin of the samples in the database. * * @return string */ public function get_samples_origin() { return 'user_enrolments'; } /** * Returns the student enrolment course context. * * @param int $sampleid * @return \context */ public function sample_access_context($sampleid) { return \context_course::instance($this->get_sample_courseid($sampleid)); } /** * Returns the student enrolment course. * * @param int $sampleid * @return \core_analytics\analysable */ public function get_sample_analysable($sampleid) { $course = enrol_get_course_by_user_enrolment_id($sampleid); return \core_analytics\course::instance($course); } /** * Data provided by get_all_samples & get_samples. * * @return string[] */ protected function provided_sample_data() { return array('user_enrolments', 'context', 'course', 'user'); } /** * We need to delete associated data if a user requests his data to be deleted. * * @return bool */ public function processes_user_data() { return true; } /** * Join the samples origin table with the user id table. * * @param string $sampletablealias * @return string */ public function join_sample_user($sampletablealias) { return "JOIN {user_enrolments} ue ON {$sampletablealias}.sampleid = ue.id " . "JOIN {user} u ON u.id = ue.userid"; } /** * All course student enrolments. * * It does return all student enrolments including the suspended ones. * * @param \core_analytics\analysable $course * @return array */ public function get_all_samples(\core_analytics\analysable $course) { $enrolments = enrol_get_course_users($course->get_id()); // We fetch all enrolments, but we are only interested in students. $studentids = $course->get_students(); $samplesdata = array(); foreach ($enrolments as $userenrolmentid => $user) { if (empty($studentids[$user->id])) { // Not a student or an analysed one. continue; } $sampleid = $userenrolmentid; $samplesdata[$sampleid]['user_enrolments'] = (object)array( 'id' => $user->ueid, 'status' => $user->uestatus, 'enrolid' => $user->ueenrolid, 'userid' => $user->id, 'timestart' => $user->uetimestart, 'timeend' => $user->uetimeend, 'modifierid' => $user->uemodifierid, 'timecreated' => $user->uetimecreated, 'timemodified' => $user->uetimemodified ); unset($user->ueid); unset($user->uestatus); unset($user->ueenrolid); unset($user->uetimestart); unset($user->uetimeend); unset($user->uemodifierid); unset($user->uetimecreated); unset($user->uetimemodified); // This student has been already analysed. We analyse each student once. unset($studentids[$user->id]); $samplesdata[$sampleid]['course'] = $course->get_course_data(); $samplesdata[$sampleid]['context'] = $course->get_context(); $samplesdata[$sampleid]['user'] = $user; // Fill the cache. $this->samplecourses[$sampleid] = $course->get_id(); } $enrolids = array_keys($samplesdata); return array(array_combine($enrolids, $enrolids), $samplesdata); } /** * Returns all samples from the samples ids. * * @param int[] $sampleids * @return array */ public function get_samples($sampleids) { global $DB; $enrolments = enrol_get_course_users(false, false, array(), $sampleids); // Some course enrolments. list($enrolsql, $params) = $DB->get_in_or_equal($sampleids, SQL_PARAMS_NAMED); $samplesdata = array(); foreach ($enrolments as $userenrolmentid => $user) { $sampleid = $userenrolmentid; $samplesdata[$sampleid]['user_enrolments'] = (object)array( 'id' => $user->ueid, 'status' => $user->uestatus, 'enrolid' => $user->ueenrolid, 'userid' => $user->id, 'timestart' => $user->uetimestart, 'timeend' => $user->uetimeend, 'modifierid' => $user->uemodifierid, 'timecreated' => $user->uetimecreated, 'timemodified' => $user->uetimemodified ); unset($user->ueid); unset($user->uestatus); unset($user->ueenrolid); unset($user->uetimestart); unset($user->uetimeend); unset($user->uemodifierid); unset($user->uetimecreated); unset($user->uetimemodified); // Enrolment samples are grouped by the course they belong to, so all $sampleids belong to the same // course, $courseid and $coursemodinfo will only query the DB once and cache the course data in memory. $courseid = $this->get_sample_courseid($sampleid); $coursemodinfo = get_fast_modinfo($courseid); $coursecontext = \context_course::instance($courseid); $samplesdata[$sampleid]['course'] = $coursemodinfo->get_course(); $samplesdata[$sampleid]['context'] = $coursecontext; $samplesdata[$sampleid]['user'] = $user; // Fill the cache. $this->samplecourses[$sampleid] = $coursemodinfo->get_course()->id; } $enrolids = array_keys($samplesdata); return array(array_combine($enrolids, $enrolids), $samplesdata); } /** * Returns the student enrolment course id. * * @param int $sampleid * @return int */ protected function get_sample_courseid($sampleid) { global $DB; if (empty($this->samplecourses[$sampleid])) { $course = enrol_get_course_by_user_enrolment_id($sampleid); $this->samplecourses[$sampleid] = $course->id; } return $this->samplecourses[$sampleid]; } /** * Returns the visible name of a sample + a renderable to display as sample picture. * * @param int $sampleid * @param int $contextid * @param array $sampledata * @return array array(string, \renderable) */ public function sample_description($sampleid, $contextid, $sampledata) { $description = fullname($sampledata['user'], true, array('context' => $contextid)); return array($description, new \user_picture($sampledata['user'])); } } analyser/users.php000064400000013307152267651410010251 0ustar00. /** * Users analyser (insights for users). * * @package core * @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\analyser; defined('MOODLE_INTERNAL') || die(); /** * Users analyser (insights for users). * * @package core * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class users extends \core_analytics\local\analyser\base { /** * The site users are the analysable elements returned by this analyser. * * @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, $CFG; $siteadmins = explode(',', $CFG->siteadmins); list($sql, $params) = $this->get_iterator_sql('user', CONTEXT_USER, $action, 'u', $contexts); $sql .= " AND u.deleted = :deleted AND u.confirmed = :confirmed AND u.suspended = :suspended"; $params = $params + ['deleted' => 0, 'confirmed' => 1, 'suspended' => 0]; $ordersql = $this->order_sql('timecreated', 'ASC', 'u'); $recordset = $DB->get_recordset_sql($sql, $params); if (!$recordset->valid()) { $this->add_log(get_string('nousersfound')); return new \ArrayIterator([]); } return new \core\dml\recordset_walk($recordset, function($record) use ($siteadmins) { if (in_array($record->id, $siteadmins) || isguestuser($record->id)) { // Skip admins and the guest user. return false; } $context = \context_helper::preload_from_record($record); return \core_analytics\user::instance($record, $context); }); } /** * Just one sample per analysable. * * @return bool */ public static function one_sample_per_analysable() { return true; } /** * Samples origin is user table. * * @return string */ public function get_samples_origin() { return 'user'; } /** * Returns the analysable of a sample * * @param int $sampleid * @return \core_analytics\analysable */ public function get_sample_analysable($sampleid) { return \core_analytics\user::instance($sampleid); } /** * This provides samples' user and context. * * @return string[] */ protected function provided_sample_data() { return ['user', 'context']; } /** * Returns the context of a sample. * * @param int $sampleid * @return \context */ public function sample_access_context($sampleid) { return \context_user::instance($sampleid); } /** * This will return just one user as we analyse users separately. * * @param \core_analytics\analysable $user * @return array */ public function get_all_samples(\core_analytics\analysable $user) { $context = \context_user::instance($user->get_id()); // Just 1 sample per analysable. return [ [$user->get_id() => $user->get_id()], [$user->get_id() => ['user' => $user->get_user_data(), 'context' => $context]] ]; } /** * Returns samples data from sample ids. * * @param int[] $sampleids * @return array */ public function get_samples($sampleids) { global $DB; list($sql, $params) = $DB->get_in_or_equal($sampleids, SQL_PARAMS_NAMED); $users = $DB->get_records_select('user', "id $sql", $params); $userids = array_keys($users); $sampleids = array_combine($userids, $userids); $users = array_map(function($user) { return ['user' => $user, 'context' => \context_user::instance($user->id)]; }, $users); // No related data attached. return [$sampleids, $users]; } /** * Returns the description of a sample. * * @param int $sampleid * @param int $contextid * @param array $sampledata * @return array array(string, \renderable) */ public function sample_description($sampleid, $contextid, $sampledata) { $description = fullname($sampledata['user']); return [$description, new \user_picture($sampledata['user'])]; } /** * We need to delete associated data if a user requests his data to be deleted. * * @return bool */ public function processes_user_data() { return true; } /** * Join the samples origin table with the user id table. * * @param string $sampletablealias * @return string */ public function join_sample_user($sampletablealias) { return "JOIN {user} u ON u.id = {$sampletablealias}.sampleid"; } }