From e4453adc55cce4f72cded3349dbd0034f357cc60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Monlla=C3=B3?= Date: Wed, 23 Jan 2019 17:11:30 +0100 Subject: [PATCH] MDL-60944 tool_analytics: Adding create and delete features Extra modifications to refine the preexisting patch. --- admin/tool/analytics/amd/build/model.min.js | 2 +- admin/tool/analytics/amd/src/model.js | 9 + .../classes/output/form/edit_model.php | 24 +- .../form/import_model.php} | 23 +- .../tool/analytics/classes/output/helper.php | 34 +++ .../analytics/classes/output/models_list.php | 15 +- admin/tool/analytics/createmodel.php | 91 +++++++ admin/tool/analytics/importmodel.php | 58 ++-- .../tool/analytics/lang/en/tool_analytics.php | 20 +- admin/tool/analytics/model.php | 50 ++-- admin/tool/analytics/settings.php | 2 - .../analytics/templates/models_list.mustache | 4 + analytics/classes/calculable.php | 1 + analytics/classes/manager.php | 28 +- analytics/classes/model.php | 103 ++++--- analytics/classes/model_config.php | 251 ++++++++++++++++++ analytics/tests/model_test.php | 101 +++++-- lib/classes/component.php | 13 +- 18 files changed, 698 insertions(+), 131 deletions(-) rename admin/tool/analytics/classes/{import_model_form.php => output/form/import_model.php} (73%) create mode 100644 admin/tool/analytics/createmodel.php create mode 100644 analytics/classes/model_config.php diff --git a/admin/tool/analytics/amd/build/model.min.js b/admin/tool/analytics/amd/build/model.min.js index e9e2e3c4dec..c66254a1542 100644 --- a/admin/tool/analytics/amd/build/model.min.js +++ b/admin/tool/analytics/amd/build/model.min.js @@ -1 +1 @@ -define(["jquery","core/str","core/log","core/notification","core/modal_factory","core/modal_events"],function(a,b,c,d,e,f){var g={clear:{title:{key:"clearpredictions",component:"tool_analytics"},body:{key:"clearmodelpredictions",component:"tool_analytics"}}},h=function(b){return a(b.closest("tr")[0]).find("span.target-name").text()};return{confirmAction:function(i,j){a('[data-action-id="'+i+'"]').on("click",function(i){i.preventDefault();var k=a(i.currentTarget);if("undefined"==typeof g[j])return void c.error('Action "'+j+'" is not allowed.');var l=[g[j].title,g[j].body];l[1].param=h(k);var m=b.get_strings(l),n=e.create({type:e.types.SAVE_CANCEL});a.when(m,n).then(function(a,b){return b.setTitle(a[0]),b.setBody(a[1]),b.setSaveButtonText(a[0]),b.getRoot().on(f.save,function(){window.location.href=k.attr("href")}),b.show(),b}).fail(d.exception)})}}}); \ No newline at end of file +define(["jquery","core/str","core/log","core/notification","core/modal_factory","core/modal_events"],function(a,b,c,d,e,f){var g={clear:{title:{key:"clearpredictions",component:"tool_analytics"},body:{key:"clearmodelpredictions",component:"tool_analytics"}},"delete":{title:{key:"delete",component:"tool_analytics"},body:{key:"deletemodelconfirmation",component:"tool_analytics"}}},h=function(b){return a(b.closest("tr")[0]).find("span.target-name").text()};return{confirmAction:function(i,j){a('[data-action-id="'+i+'"]').on("click",function(i){i.preventDefault();var k=a(i.currentTarget);if("undefined"==typeof g[j])return void c.error('Action "'+j+'" is not allowed.');var l=[g[j].title,g[j].body];l[1].param=h(k);var m=b.get_strings(l),n=e.create({type:e.types.SAVE_CANCEL});a.when(m,n).then(function(a,b){return b.setTitle(a[0]),b.setBody(a[1]),b.setSaveButtonText(a[0]),b.getRoot().on(f.save,function(){window.location.href=k.attr("href")}),b.show(),b}).fail(d.exception)})}}}); \ No newline at end of file diff --git a/admin/tool/analytics/amd/src/model.js b/admin/tool/analytics/amd/src/model.js index 891343143ff..2f0b605161f 100644 --- a/admin/tool/analytics/amd/src/model.js +++ b/admin/tool/analytics/amd/src/model.js @@ -36,6 +36,15 @@ define(['jquery', 'core/str', 'core/log', 'core/notification', 'core/modal_facto component: 'tool_analytics' } + }, + 'delete': { + title: { + key: 'delete', + component: 'tool_analytics' + }, body: { + key: 'deletemodelconfirmation', + component: 'tool_analytics' + } } }; diff --git a/admin/tool/analytics/classes/output/form/edit_model.php b/admin/tool/analytics/classes/output/form/edit_model.php index 91fb876101f..2c0be865102 100644 --- a/admin/tool/analytics/classes/output/form/edit_model.php +++ b/admin/tool/analytics/classes/output/form/edit_model.php @@ -45,13 +45,25 @@ class edit_model extends \moodleform { $mform = $this->_form; - if ($this->_customdata['model']->is_trained()) { + if ($this->_customdata['trainedmodel']) { $message = get_string('edittrainedwarning', 'tool_analytics'); $mform->addElement('html', $OUTPUT->notification($message, \core\output\notification::NOTIFY_WARNING)); } $mform->addElement('advcheckbox', 'enabled', get_string('enabled', 'tool_analytics')); + if (!empty($this->_customdata['targets'])) { + $targets = array('' => ''); + foreach ($this->_customdata['targets'] as $classname => $target) { + $optionname = \tool_analytics\output\helper::class_to_option($classname); + $targets[$optionname] = $target->get_name(); + } + + $mform->addElement('select', 'target', get_string('target', 'tool_analytics'), $targets); + $mform->addHelpButton('target', 'target', 'tool_analytics'); + $mform->addRule('target', get_string('required'), 'required', null, 'client'); + } + $indicators = array(); foreach ($this->_customdata['indicators'] as $classname => $indicator) { $optionname = \tool_analytics\output\helper::class_to_option($classname); @@ -88,11 +100,13 @@ class edit_model extends \moodleform { $predictionprocessors); $mform->addHelpButton('predictionsprocessor', 'predictionsprocessor', 'analytics'); - $mform->addElement('hidden', 'id', $this->_customdata['id']); - $mform->setType('id', PARAM_INT); + if (!empty($this->_customdata['id'])) { + $mform->addElement('hidden', 'id', $this->_customdata['id']); + $mform->setType('id', PARAM_INT); - $mform->addElement('hidden', 'action', 'edit'); - $mform->setType('action', PARAM_ALPHANUMEXT); + $mform->addElement('hidden', 'action', 'edit'); + $mform->setType('action', PARAM_ALPHANUMEXT); + } $this->add_action_buttons(); } diff --git a/admin/tool/analytics/classes/import_model_form.php b/admin/tool/analytics/classes/output/form/import_model.php similarity index 73% rename from admin/tool/analytics/classes/import_model_form.php rename to admin/tool/analytics/classes/output/form/import_model.php index ae0709e2a5a..8c17e395797 100644 --- a/admin/tool/analytics/classes/import_model_form.php +++ b/admin/tool/analytics/classes/output/form/import_model.php @@ -22,9 +22,9 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -namespace tool_analytics; -defined('MOODLE_INTERNAL') || die(); +namespace tool_analytics\output\form; +defined('MOODLE_INTERNAL') || die(); /** * Model upload form. @@ -33,15 +33,24 @@ defined('MOODLE_INTERNAL') || die(); * @copyright 2017 onwards Ankit Agarwal * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class import_model_form extends \moodleform { - function definition () { +class import_model extends \moodleform { + + /** + * Form definition. + * + * @return null + */ + public function definition () { $mform = $this->_form; - $mform->addElement('header', 'settingsheader', get_string('analyticsimportmodel', 'tool_analytics')); + $mform->addElement('header', 'settingsheader', get_string('importmodel', 'tool_analytics')); $mform->addElement('filepicker', 'modelfile', get_string('file'), null, ['accepted_types' => '.json']); $mform->addRule('modelfile', null, 'required'); - $this->add_action_buttons(false, get_string('submit')); + $mform->addElement('advcheckbox', 'ignoreversionmismatches', get_string('ignoreversionmismatches', 'tool_analytics'), + get_string('ignoreversionmismatchescheckbox', 'tool_analytics')); + + $this->add_action_buttons(true, get_string('import')); } -} \ No newline at end of file +} diff --git a/admin/tool/analytics/classes/output/helper.php b/admin/tool/analytics/classes/output/helper.php index 8d121e263b6..59b33d838e8 100644 --- a/admin/tool/analytics/classes/output/helper.php +++ b/admin/tool/analytics/classes/output/helper.php @@ -58,4 +58,38 @@ class helper { // Really unlikely but yeah, I'm a bad booyyy. return str_replace('2015102400ouuu', '\\', $option); } + + /** + * Sets an analytics > analytics models > $title breadcrumb. + * + * @param string $title + * @param \moodle_url $url + * @param \context|false $context Defaults to context_system + * @return null + */ + public static function set_navbar(string $title, \moodle_url $url, ?\context $context = null) { + global $PAGE; + + if (!$context) { + $context = \context_system::instance(); + } + + $PAGE->set_context($context); + $PAGE->set_url($url); + + if ($siteadmin = $PAGE->settingsnav->find('root', \navigation_node::TYPE_SITE_ADMIN)) { + $PAGE->navbar->add($siteadmin->get_content(), $siteadmin->action()); + } + if ($analytics = $PAGE->settingsnav->find('analytics', \navigation_node::TYPE_SETTING)) { + $PAGE->navbar->add($analytics->get_content(), $analytics->action()); + } + if ($analyticmodels = $PAGE->settingsnav->find('analyticmodels', \navigation_node::TYPE_SETTING)) { + $PAGE->navbar->add($analyticmodels->get_content(), $analyticmodels->action()); + } + $PAGE->navbar->add($title, $url); + + $PAGE->set_pagelayout('report'); + $PAGE->set_title($title); + $PAGE->set_heading($title); + } } diff --git a/admin/tool/analytics/classes/output/models_list.php b/admin/tool/analytics/classes/output/models_list.php index a0785df26c7..b3a1c43797a 100644 --- a/admin/tool/analytics/classes/output/models_list.php +++ b/admin/tool/analytics/classes/output/models_list.php @@ -62,6 +62,8 @@ class models_list implements \renderable, \templatable { global $PAGE; $data = new \stdClass(); + $data->importmodelurl = new \moodle_url('/admin/tool/analytics/importmodel.php'); + $data->createmodelurl = new \moodle_url('/admin/tool/analytics/createmodel.php'); $onlycli = get_config('analytics', 'onlycli'); if ($onlycli === false) { @@ -232,7 +234,7 @@ class models_list implements \renderable, \templatable { $urlparams['action'] = 'exportdata'; $url = new \moodle_url('model.php', $urlparams); $icon = new \action_menu_link_secondary($url, new \pix_icon('i/export', - get_string('exporttrainingdata', 'tool_analytics')), get_string('export', 'tool_analytics')); + get_string('exporttrainingdata', 'tool_analytics')), get_string('exporttrainingdata', 'tool_analytics')); $actionsmenu->add($icon); } @@ -240,7 +242,7 @@ class models_list implements \renderable, \templatable { if (!$model->is_static() && $model->get_indicators() && !empty($modeldata->timesplitting)) { $urlparams['action'] = 'exportmodel'; $url = new \moodle_url('model.php', $urlparams); - $icon = new \action_menu_link_secondary($url, new \pix_icon('i/export', + $icon = new \action_menu_link_secondary($url, new \pix_icon('i/backup', get_string('exportmodel', 'tool_analytics')), get_string('exportmodel', 'tool_analytics')); $actionsmenu->add($icon); } @@ -267,6 +269,15 @@ class models_list implements \renderable, \templatable { $actionsmenu->add($icon); } + $actionid = 'delete-' . $model->get_id(); + $PAGE->requires->js_call_amd('tool_analytics/model', 'confirmAction', [$actionid, 'delete']); + $urlparams['action'] = 'delete'; + $url = new \moodle_url('model.php', $urlparams); + $icon = new \action_menu_link_secondary($url, new \pix_icon('t/delete', + get_string('delete', 'tool_analytics')), get_string('delete', 'tool_analytics'), + ['data-action-id' => $actionid]); + $actionsmenu->add($icon); + $modeldata->actions = $actionsmenu->export_for_template($output); $data->models[] = $modeldata; diff --git a/admin/tool/analytics/createmodel.php b/admin/tool/analytics/createmodel.php new file mode 100644 index 00000000000..8ee0f4d9d37 --- /dev/null +++ b/admin/tool/analytics/createmodel.php @@ -0,0 +1,91 @@ +. + +/** + * Create model form. + * + * @package tool_analytics + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once(__DIR__ . '/../../../config.php'); + +require_login(); +\core_analytics\manager::check_can_manage_models(); + +$returnurl = new \moodle_url('/admin/tool/analytics/index.php'); +$url = new \moodle_url('/admin/tool/analytics/createmodel.php'); +$title = get_string('createmodel', 'tool_analytics'); + +\tool_analytics\output\helper::set_navbar($title, $url); + +// Static targets are not editable, we discard them. +$targets = array_filter(\core_analytics\manager::get_all_targets(), function($target) { + return (!$target->based_on_assumptions()); +}); + +$customdata = array( + 'trainedmodel' => false, + 'targets' => $targets, + 'indicators' => \core_analytics\manager::get_all_indicators(), + 'timesplittings' => \core_analytics\manager::get_enabled_time_splitting_methods(), + 'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors(), +); +$mform = new \tool_analytics\output\form\edit_model(null, $customdata); + +if ($mform->is_cancelled()) { + redirect($returnurl); + +} else if ($data = $mform->get_data()) { + + // Converting option names to class names. + $targetclass = \tool_analytics\output\helper::option_to_class($data->target); + $target = \core_analytics\manager::get_target($targetclass); + + $indicators = array(); + foreach ($data->indicators as $indicator) { + $indicatorinstance = \core_analytics\manager::get_indicator( + \tool_analytics\output\helper::option_to_class($indicator) + ); + $indicators[$indicatorinstance->get_id()] = $indicatorinstance; + } + $timesplitting = \tool_analytics\output\helper::option_to_class($data->timesplitting); + $predictionsprocessor = \tool_analytics\output\helper::option_to_class($data->predictionsprocessor); + + // Insert the model into db. + $model = \core_analytics\model::create($target, []); + + // Filter out indicators that can not be used by this target. + $invalidindicators = array_diff_key($indicators, $model->get_potential_indicators()); + if ($invalidindicators) { + $indicators = array_diff_key($indicators, $invalidindicators); + } + + // Update the model with the valid list of indicators. + $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor); + + $message = ''; + $messagetype = \core\output\notification::NOTIFY_SUCCESS; + if (!empty($invalidindicators)) { + $message = get_string('invalidindicatorsremoved', 'tool_analytics'); + } + redirect($returnurl, $message, 0, $messagetype); +} + +echo $OUTPUT->header(); +$mform->display(); +echo $OUTPUT->footer(); \ No newline at end of file diff --git a/admin/tool/analytics/importmodel.php b/admin/tool/analytics/importmodel.php index 18d2e314a2b..250b14f4ad5 100644 --- a/admin/tool/analytics/importmodel.php +++ b/admin/tool/analytics/importmodel.php @@ -23,35 +23,41 @@ */ require_once(__DIR__ . '/../../../config.php'); -require_once($CFG->libdir . '/adminlib.php'); -admin_externalpage_setup('analyticsmodelimport', '', null, '', array('pagelayout' => 'report')); -echo $OUTPUT->header(); +require_login(); +\core_analytics\manager::check_can_manage_models(); -$form = new tool_analytics\import_model_form(); -if ($data = $form->get_data()) { - $content = json_decode($form->get_file_content('modelfile')); - if (empty($content->moodleversion)) { - // Should never happen. - echo $OUTPUT->notification(get_string('missingmoodleversion', 'tool_analytics'), 'error'); - } else { - if ($content->moodleversion != $CFG->version) { - $a = new stdClass(); - $a->importedversion = $content->moodleversion; - $a->version = $CFG->version; - echo $OUTPUT->notification(get_string('versionnotsame', 'tool_analytics', $a), 'warning'); - } - $model = \core_analytics\model::create_from_json($content); - if ($model) { - echo $OUTPUT->notification(get_string('success'), 'notifysuccess'); - } else { - echo $OUTPUT->notification(get_string('error'), 'error'); - } +$returnurl = new \moodle_url('/admin/tool/analytics/index.php'); +$url = new \moodle_url('/admin/tool/analytics/importmodel.php'); +$title = get_string('importmodel', 'tool_analytics'); + +\tool_analytics\output\helper::set_navbar($title, $url); + +$form = new \tool_analytics\output\form\import_model(); +if ($form->is_cancelled()) { + redirect($returnurl); +} else if ($data = $form->get_data()) { + + $modelconfig = new \core_analytics\model_config(); + + $json = $form->get_file_content('modelfile'); + + if ($error = $modelconfig->check_json_data($json)) { + // The provided file is not ok. + redirect($url, $error, 0, \core\output\notification::NOTIFY_ERROR); } - echo $OUTPUT->single_button(new moodle_url("$CFG->wwwroot/$CFG->admin/tool/analytics/index.php"), - get_string('continue'), 'get'); -} else { - $form->display(); + + $modeldata = json_decode($json); + if ($error = $modelconfig->check_dependencies($modeldata, $data->ignoreversionmismatches)) { + // The file is not available until the form is validated so we need an alternative method to show errors. + redirect($url, $error, 0, \core\output\notification::NOTIFY_ERROR); + } + $model = \core_analytics\model::create_from_import($modeldata, true); + + redirect($returnurl, get_string('importedsuccessfully', 'tool_analytics'), 0, + \core\output\notification::NOTIFY_SUCCESS); } +echo $OUTPUT->header(); +$form->display(); echo $OUTPUT->footer(); \ No newline at end of file diff --git a/admin/tool/analytics/lang/en/tool_analytics.php b/admin/tool/analytics/lang/en/tool_analytics.php index 9844c58cf1d..267f96a32df 100644 --- a/admin/tool/analytics/lang/en/tool_analytics.php +++ b/admin/tool/analytics/lang/en/tool_analytics.php @@ -26,7 +26,6 @@ $string['accuracy'] = 'Accuracy'; $string['allpredictions'] = 'All predictions'; $string['analysingsitedata'] = 'Analysing the site'; $string['analyticmodels'] = 'Analytics models'; -$string['analyticsimportmodel'] = 'Import analytics model'; $string['bettercli'] = 'Evaluating models and generating predictions may involve heavy processing. It is recommended to run these actions from the command line.'; $string['cantguessstartdate'] = 'Can\'t guess the start date'; $string['cantguessenddate'] = 'Can\'t guess the end date'; @@ -36,15 +35,23 @@ $string['clearmodelpredictions'] = 'Are you sure you want to clear all "{$a}" pr $string['clienablemodel'] = 'You can enable the model by selecting a time-splitting method by its ID. Note that you can also enable it later using the web interface (\'none\' to exit).'; $string['clievaluationandpredictions'] = 'A scheduled task iterates through enabled models and gets predictions. Models evaluation via the web interface is disabled. You can allow these processes to be executed manually via the web interface by disabling the \'onlycli\' analytics setting.'; $string['clievaluationandpredictionsnoadmin'] = 'A scheduled task iterates through enabled models and gets predictions. Models evaluation via the web interface is disabled. It may be enabled by a site administrator.'; +$string['createmodel'] = 'Create model'; +$string['delete'] = 'Delete'; +$string['deletemodelconfirmation'] = 'Are you sure you want to delete "{$a}"?'; $string['disabled'] = 'Disabled'; $string['editmodel'] = 'Edit "{$a}" model'; $string['edittrainedwarning'] = 'This model has already been trained. Note that changing its indicators or its time-splitting method will delete its previous predictions and start generating new predictions.'; $string['enabled'] = 'Enabled'; $string['errorcantenablenotimesplitting'] = 'You need to select a time-splitting method before enabling the model'; +$string['errorimport'] = 'Error importing the provided json file.'; +$string['errorimportmissingcomponents'] = 'The provided model requires the following plugins to be installed: {$a}. Note that the versions do not necessarily need to match with the versions installed in your system. To install the same or a newer version of the plugin should be enough in most cases.'; +$string['errorimportversionmismatches'] = 'The version of the following components differ from the version installed in this site: {$a}. You can use "Ignore version mismatches" option to ignore these differences.'; +$string['errorimportmissingclasses'] = 'The following analytics components are not available in this site: {$a->missingclasses}. '; $string['errornoenabledandtrainedmodels'] = 'There are no enabled and trained models to predict.'; $string['errornoenabledmodels'] = 'There are no enabled models to train.'; $string['errornoexport'] = 'Only trained models can be exported'; -$string['errornoexportconfg'] = 'Only non static models with timeplitting methods can be exported.'; +$string['errornoexportconfig'] = 'There was a problem exporting the model configuration.'; +$string['errornoexportconfigrequirements'] = 'Only non static models with timeplitting methods can be exported.'; $string['errornostaticedit'] = 'Models based on assumptions cannot be edited.'; $string['errornostaticevaluated'] = 'Models based on assumptions cannot be evaluated. They are always 100% correct according to how they were defined.'; $string['errornostaticlog'] = 'Models based on assumptions cannot be evaluated because there is no performance log.'; @@ -53,8 +60,7 @@ $string['errortrainingdataexport'] = 'The model training data could not be expor $string['evaluate'] = 'Evaluate'; $string['evaluatemodel'] = 'Evaluate model'; $string['evaluationinbatches'] = 'The site contents are calculated and stored in batches. The evaluation process may be stopped at any time. The next time it is run, it will continue from the point when it was stopped.'; -$string['export'] = 'Export'; -$string['exportmodel'] = 'Export model configuration'; +$string['exportmodel'] = 'Export configuration'; $string['exporttrainingdata'] = 'Export training data'; $string['getpredictionsresultscli'] = 'Results using {$a->name} (id: {$a->id}) course duration splitting'; $string['getpredictionsresults'] = 'Results using {$a->name} course duration splitting'; @@ -62,12 +68,17 @@ $string['extrainfo'] = 'Info'; $string['generalerror'] = 'Evaluation error. Status code {$a}'; $string['getpredictions'] = 'Get predictions'; $string['goodmodel'] = 'This is a good model for using to obtain predictions. Enable it to start obtaining predictions.'; +$string['importmodel'] = 'Import model'; $string['indicators'] = 'Indicators'; $string['info'] = 'Info'; +$string['ignoreversionmismatches'] = 'Ignore version mismatches'; +$string['ignoreversionmismatchescheckbox'] = 'Ignore the differences between this site version and the original site version.'; +$string['importedsuccessfully'] = 'The model has been successfully imported.'; $string['insights'] = 'Insights'; $string['invalidanalysables'] = 'Invalid site elements'; $string['invalidanalysablesinfo'] = 'This pages lists this site analysable elements that can not be used by this prediction model. The listed elements can not be used neither to train the prediction model nor the prediction model can get predictions for them.'; $string['invalidanalysablestable'] = 'Invalid site analysable elements table'; +$string['invalidindicatorsremoved'] = 'A new model has been added. Indicators that do not work with the selected target have been automatically removed.'; $string['invalidprediction'] = 'Invalid to get predictions'; $string['invalidtraining'] = 'Invalid to train the model'; $string['loginfo'] = 'Log extra info'; @@ -91,6 +102,7 @@ $string['previouspage'] = 'Previous page'; $string['samestartdate'] = 'Current start date is good'; $string['sameenddate'] = 'Current end date is good'; $string['target'] = 'Target'; +$string['target_help'] = 'The target is what the model will predict.'; $string['timesplittingnotdefined'] = 'Time splitting is not defined.'; $string['timesplittingnotdefined_help'] = 'You need to select a time-splitting method before enabling the model.'; $string['trainandpredictmodel'] = 'Training model and calculating predictions'; diff --git a/admin/tool/analytics/model.php b/admin/tool/analytics/model.php index 5e5f72a9529..f385410e41a 100644 --- a/admin/tool/analytics/model.php +++ b/admin/tool/analytics/model.php @@ -23,18 +23,17 @@ */ require_once(__DIR__ . '/../../../config.php'); -require_once($CFG->libdir . '/dataformatlib.php'); +require_once($CFG->libdir . '/filelib.php'); $id = required_param('id', PARAM_INT); $action = required_param('action', PARAM_ALPHANUMEXT); -$context = context_system::instance(); - require_login(); $model = new \core_analytics\model($id); \core_analytics\manager::check_can_manage_models(); +$returnurl = new \moodle_url('/admin/tool/analytics/index.php'); $params = array('id' => $id, 'action' => $action); $url = new \moodle_url('/admin/tool/analytics/model.php', $params); @@ -58,6 +57,9 @@ switch ($action) { case 'disable': $title = get_string('disable'); break; + case 'delete': + $title = get_string('delete'); + break; case 'exportdata': $title = get_string('exporttrainingdata', 'tool_analytics'); break; @@ -74,11 +76,7 @@ switch ($action) { throw new moodle_exception('errorunknownaction', 'analytics'); } -$PAGE->set_context($context); -$PAGE->set_url($url); -$PAGE->set_pagelayout('report'); -$PAGE->set_title($title); -$PAGE->set_heading($title); +\tool_analytics\output\helper::set_navbar($title, $url); $onlycli = get_config('analytics', 'onlycli'); if ($onlycli === false) { @@ -92,14 +90,21 @@ switch ($action) { confirm_sesskey(); $model->enable(); - redirect(new \moodle_url('/admin/tool/analytics/index.php')); + redirect($returnurl); break; case 'disable': confirm_sesskey(); $model->update(0, false, false); - redirect(new \moodle_url('/admin/tool/analytics/index.php')); + redirect($returnurl); + break; + + case 'delete': + confirm_sesskey(); + + $model->delete(); + redirect($returnurl); break; case 'edit': @@ -112,7 +117,7 @@ switch ($action) { $customdata = array( 'id' => $model->get_id(), - 'model' => $model, + 'trainedmodel' => $model->is_trained(), 'indicators' => $model->get_potential_indicators(), 'timesplittings' => \core_analytics\manager::get_enabled_time_splitting_methods(), 'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors() @@ -120,7 +125,7 @@ switch ($action) { $mform = new \tool_analytics\output\form\edit_model(null, $customdata); if ($mform->is_cancelled()) { - redirect(new \moodle_url('/admin/tool/analytics/index.php')); + redirect($returnurl); } else if ($data = $mform->get_data()) { @@ -133,7 +138,7 @@ switch ($action) { $timesplitting = \tool_analytics\output\helper::option_to_class($data->timesplitting); $predictionsprocessor = \tool_analytics\output\helper::option_to_class($data->predictionsprocessor); $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor); - redirect(new \moodle_url('/admin/tool/analytics/index.php')); + redirect($returnurl); } echo $OUTPUT->header(); @@ -215,7 +220,7 @@ switch ($action) { $file = $model->get_training_data(); if (!$file) { - redirect(new \moodle_url('/admin/tool/analytics/index.php'), get_string('errortrainingdataexport', 'tool_analytics'), + redirect($returnurl, get_string('errortrainingdataexport', 'tool_analytics'), null, \core\output\notification::NOTIFY_ERROR); } @@ -224,26 +229,17 @@ switch ($action) { break; case 'exportmodel': - - if (!$model->is_static() && $model->get_indicators() && !empty($model->timesplitting)) { - throw new moodle_exception('errornoexportconfg', 'tool_analytics'); - } - $downloadfilename = 'model-config.' . $model->get_id() . '.' . time() . '.json'; - $modelconfig = $model->export_as_json(); - make_temp_directory('analyticsexport'); - $tempfilename = $CFG->tempdir .'/analyticsexport/'. md5(sesskey() . microtime() . $downloadfilename); - if (!file_put_contents($tempfilename, $modelconfig)) { - print_error('cannotcreatetempdir'); - } + $downloadfilename = 'model-config.' . $model->get_id() . '.' . microtime() . '.json'; + $filepath = $model->export_config($downloadfilename); @header("Content-type: text/json; charset=UTF-8"); - send_temp_file($tempfilename, $downloadfilename); + send_temp_file($filepath, $downloadfilename); break; case 'clear': confirm_sesskey(); $model->clear(); - redirect(new \moodle_url('/admin/tool/analytics/index.php')); + redirect($returnurl); break; case 'invalidanalysables': diff --git a/admin/tool/analytics/settings.php b/admin/tool/analytics/settings.php index c70405ed68f..aad459a5023 100644 --- a/admin/tool/analytics/settings.php +++ b/admin/tool/analytics/settings.php @@ -26,5 +26,3 @@ defined('MOODLE_INTERNAL') || die(); $ADMIN->add('analytics', new admin_externalpage('analyticmodels', get_string('analyticmodels', 'tool_analytics'), "$CFG->wwwroot/$CFG->admin/tool/analytics/index.php", 'moodle/analytics:managemodels')); -$ADMIN->add('analytics', new admin_externalpage('analyticsmodelimport', get_string('analyticsimportmodel', 'tool_analytics'), - "$CFG->wwwroot/$CFG->admin/tool/analytics/importmodel.php", 'moodle/analytics:managemodels')); diff --git a/admin/tool/analytics/templates/models_list.mustache b/admin/tool/analytics/templates/models_list.mustache index efdc4cabab5..bd11e0b0a3e 100644 --- a/admin/tool/analytics/templates/models_list.mustache +++ b/admin/tool/analytics/templates/models_list.mustache @@ -110,6 +110,10 @@ {{/infos}}
+ diff --git a/analytics/classes/calculable.php b/analytics/classes/calculable.php index af95120f510..ceea63a95eb 100644 --- a/analytics/classes/calculable.php +++ b/analytics/classes/calculable.php @@ -82,6 +82,7 @@ abstract class calculable { * @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); } diff --git a/analytics/classes/manager.php b/analytics/classes/manager.php index ddc70e0dd06..1c10cdee4e6 100644 --- a/analytics/classes/manager.php +++ b/analytics/classes/manager.php @@ -45,6 +45,11 @@ class manager { */ protected static $predictionprocessors = null; + /** + * @var \core_analytics\local\target\base[] + */ + protected static $alltargets = null; + /** * @var \core_analytics\local\indicator\base[] */ @@ -281,6 +286,28 @@ class manager { 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. * @@ -297,7 +324,6 @@ class manager { foreach ($classes as $fullclassname => $classpath) { $instance = self::get_indicator($fullclassname); if ($instance) { - // Using get_class as get_component_classes_in_namespace returns double escaped fully qualified class names. self::$allindicators[$instance->get_id()] = $instance; } } diff --git a/analytics/classes/model.php b/analytics/classes/model.php index 97c646032d0..839b7e2beec 100644 --- a/analytics/classes/model.php +++ b/analytics/classes/model.php @@ -339,6 +339,7 @@ class model { * @param \core_analytics\local\target\base $target * @param \core_analytics\local\indicator\base[] $indicators * @param string $timesplittingid The time splitting method id (its fully qualified class name) + * @param string $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, @@ -360,8 +361,8 @@ class model { $modelobj->usermodified = $USER->id; if ($processor && - !self::is_valid($processor, '\core_analytics\classifier') && - !self::is_valid($processor, '\core_analytics\regressor')) { + !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; @@ -386,41 +387,56 @@ class model { } /** - * Creates a new model from json configuration. + * Creates a new model from import configuration. * - * @param string $json json data. - * @return \core_analytics\model + * It is recommended to call \core_analytics\model_config::check_dependencies first so the error message can be retrieved. + * + * @param \stdClass $modeldata Model data. + * @param bool $skipcheckdependencies Useful if you already checked the dependencies. + * @return \core_analytics\model|false False if the provided model data contain errors. */ - public static function create_from_json($jsondata) { + public static function create_from_import(\stdClass $modeldata, ?bool $skipcheckdependencies = false) : ?\core_analytics\model { \core_analytics\manager::check_can_manage_models(); - if (empty($jsondata) || !isset($jsondata->target) || !isset($jsondata->indicators) || !isset($jsondata->timesplitting)) { - throw new \coding_exception("invalid json data"); + + if (!$skipcheckdependencies) { + $modelconfig = new model_config(); + if ($error = $modelconfig->check_dependencies($modeldata, false)) { + return null; + } } - // Target. - $target = $jsondata->target; - if (!class_exists($target)) { - throw new \moodle_exception('classdoesnotexist', 'tool_analytics', $target); + // At this stage we should be 100% sure that the model data is safe and can be imported. + // If the caller explicitly set $skipcheckdependencies to false and there is a problem + // in this process we trigger a coding exception. + if (!$target = \core_analytics\manager::get_target($modeldata->target)) { + throw new \coding_exception('The provided target is not available. Ensure that model_config::check_dependencies + is called before importing the model.'); + } + if (!$timesplitting = \core_analytics\manager::get_time_splitting($modeldata->timesplitting)) { + throw new \coding_exception('The provided time splitting method is not available. Ensure that + model_config::check_dependencies is called before importing the model.'); } - $target = \core_analytics\manager::get_target($target); // Indicators. $indicators = []; - foreach($jsondata->indicators as $indicator) { - if (!class_exists($indicator)) { - throw new \moodle_exception('classdoesnotexist', 'tool_analytics', $indicator); + foreach ($modeldata->indicators as $indicator) { + if (!$indicator = \core_analytics\manager::get_indicator($indicator)) { + throw new \coding_exception('The provided indicator is not available. Ensure that + model_config::check_dependencies is called before importing the model.'); } - $indicators[] = \core_analytics\manager::get_indicator($indicator); + $indicators[] = $indicator; } - // Timesplitting. - $timesplitting = $jsondata->timesplitting; - if (!class_exists($timesplitting)) { - throw new \moodle_exception('classdoesnotexist', 'tool_analytics', $timesplitting); + if (!empty($modeldata->processor)) { + if (!$processor = \core_analytics\manager::get_predictions_processor($modeldata->processor, false)) { + throw new \coding_exception('The provided machine learning backend is not available. Ensure that + model_config::check_dependencies is called before importing the model.'); + } + } else { + $modeldata->processor = false; } - - return self::create($target, $indicators, $timesplitting); + return self::create($target, $indicators, $modeldata->timesplitting, $modeldata->processor); } /** @@ -500,6 +516,7 @@ class model { // 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; @@ -1418,30 +1435,40 @@ class model { } /** - * Exports the model data as JSON. + * Exports the model data as a JSON file. * - * @return string JSON encoded data. + * @param string $downloadfilename Download file name. + * @return string The filepath */ - public function export_as_json() { + public function export_config(string $downloadfilename) : string { global $CFG; - $data = new \stdClass(); - $data->target = $this->get_target()->get_id(); + \core_analytics\manager::check_can_manage_models(); + $modelconfig = new model_config($this); + $modeldata = $modelconfig->export(); + return $modelconfig->export_to_file($modeldata, $downloadfilename); + } - if ($timesplitting = $this->get_time_splitting()) { - $data->timesplitting = $timesplitting->get_id(); - } else { - // We don't want to allow models without timesplitting to be exported. - throw new \moodle_exception('errornotimesplittings', 'analytics'); + /** + * 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; } - $data->indicators = []; - foreach ($this->get_indicators() as $indicator) { - $data->indicators[] = $indicator->get_id(); + if ($this->is_static()) { + return false; } - $data->moodleversion = $CFG->version; - return json_encode($data); + + return true; } /** diff --git a/analytics/classes/model_config.php b/analytics/classes/model_config.php new file mode 100644 index 00000000000..162fc15751d --- /dev/null +++ b/analytics/classes/model_config.php @@ -0,0 +1,251 @@ +. + +/** + * 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; + + /** + * Constructor. + * + * @param \core_analytics\model|null $model + */ + public function __construct(?model $model = null) { + $this->model = $model; + } + + /** + * Exports a model to a temp file using the provided file name. + * + * @return \stdClass + */ + public function export() : \stdClass { + + if (!$this->model) { + throw new \coding_exception('No model object provided.'); + } + + if (!$this->model->can_export_configuration()) { + throw new \moodle_exception('errornoexportconfigrequirements', 'tool_analytics'); + } + + $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; + } + + if ($processor = $this->model->get_model_obj()->predictionsprocessor) { + $data->processor = $processor; + } + // Add information for versioning. + $data->dependencies = []; + foreach ($requiredclasses as $fullclassname) { + $component = $this->get_class_component($fullclassname); + $data->dependencies[$component] = $versions[$component]; + } + + return $data; + } + + /** + * Packages the configuration of a model into a .json file. + * + * @param \stdClass $data Model config data + * @param string $downloadfilename The file name. + * @return string Path to the file with the model configuration. + */ + public function export_to_file(\stdClass $data, string $downloadfilename) : string { + + $modelconfig = json_encode($data); + + $dir = make_temp_directory('analyticsexport'); + $filepath = $dir . DIRECTORY_SEPARATOR . $downloadfilename; + if (!file_put_contents($filepath, $modelconfig)) { + print_error('errornoexportconfig', 'tool_analytics'); + } + + return $filepath; + } + + /** + * Check the provided json string. + * + * @param string $json A json string. + * @return string|null Error string or null if all good. + */ + public function check_json_data(string $json) : ?string { + + if (!$modeldata = json_decode($json)) { + return get_string('errorimport', 'tool_analytics'); + } + + if (empty($modeldata->target) || empty($modeldata->timesplitting) || empty($modeldata->indicators)) { + return get_string('errorimport', 'tool_analytics'); + } + + return null; + } + + /** + * Check that the provided model configuration can be deployed in this site. + * + * @param \stdClass $importmodel + * @param bool $ignoreversionmismatches + * @return string|null Error string or null if all good. + */ + public function check_dependencies(\stdClass $importmodel, 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 ($importmodel->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 the each of the components is available. + if (!$target = manager::get_target($importmodel->target)) { + $missingclasses[] = $importmodel->target; + } + + if (!$timesplitting = manager::get_time_splitting($importmodel->timesplitting)) { + $missingclasses[] = $importmodel->timesplitting; + } + + // Indicators. + $indicators = []; + foreach ($importmodel->indicators as $indicatorclass) { + if (!$indicator = manager::get_indicator($indicatorclass)) { + $missingclasses[] = $indicatorclass; + } + } + + // ML backend. + if (!empty($importmodel->processor)) { + if (!$processor = \core_analytics\manager::get_predictions_processor($importmodel->processor, false)) { + $missingclasses[] = $indicatorclass; + } + } + + if (!empty($missingcomponents)) { + return get_string('errorimportmissingcomponents', 'tool_analytics', join(', ', $missingcomponents)); + } + + if (!empty($versionmismatches)) { + return get_string('errorimportversionmismatches', 'tool_analytics', implode(', ', $versionmismatches)); + } + + if (!empty($missingclasses)) { + $a = (object)[ + 'missingclasses' => implode(', ', $missingclasses), + 'dependencyversions' => implode(', ', $dependencyversions) + ]; + return get_string('errorimportmissingclasses', 'tool_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; + } +} diff --git a/analytics/tests/model_test.php b/analytics/tests/model_test.php index a4e18d411dd..782c916c899 100644 --- a/analytics/tests/model_test.php +++ b/analytics/tests/model_test.php @@ -28,6 +28,7 @@ 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_analyser.php'); @@ -318,33 +319,99 @@ class analytics_model_testcase extends advanced_testcase { } /** - * Test export_as_json() API. + * Test model_config::get_class_component. */ - public function test_export_as_json() { - global $CFG; + public function test_model_config_get_class_component() { $this->resetAfterTest(true); - $this->model->enable('\core\analytics\time_splitting\quarters'); - $obj = json_decode($this->model->export_as_json()); - $this->assertSame($CFG->version, $obj->moodleversion); - $this->assertSame($this->modelobj->target, $obj->target); - $this->assertSame(json_decode($this->modelobj->indicators), $obj->indicators); - $this->assertSame($this->modelobj->timesplitting, $obj->timesplitting); + $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 export_from_json() API. */ - public function test_create_from_json() { - global $CFG; + public function test_create_from_import() { $this->resetAfterTest(true); - $this->model->enable('\core\analytics\time_splitting\quarters'); - $json = $this->model->export_as_json(); - $obj = \core_analytics\model::create_from_json(json_decode($json))->get_model_obj(); - $this->assertSame($this->modelobj->target, $obj->target); - $this->assertSame($this->modelobj->indicators, $obj->indicators); - $this->assertSame($this->modelobj->timesplitting, $obj->timesplitting); + $this->model->enable('\\core\\analytics\\time_splitting\\quarters'); + + $this->modelobj = $this->model->get_model_obj(); + + $modelconfig = new \core_analytics\model_config($this->model); + $modeldata = $modelconfig->export(); + $importedmodel = \core_analytics\model::create_from_import($modeldata)->get_model_obj(); + + $this->assertSame($this->modelobj->target, $importedmodel->target); + $this->assertSame($this->modelobj->indicators, $importedmodel->indicators); + $this->assertSame($this->modelobj->timesplitting, $importedmodel->timesplitting); + $this->assertEmpty($importedmodel->predictionsprocessor); + + $this->model->update(true, false, false, '\\mlbackend_php\\processor'); + + $this->modelobj = $this->model->get_model_obj(); + + $modelconfig = new \core_analytics\model_config($this->model); + $modeldata = $modelconfig->export(); + $importedmodel = \core_analytics\model::create_from_import($modeldata)->get_model_obj(); + + $this->assertSame($this->modelobj->target, $importedmodel->target); + $this->assertSame($this->modelobj->indicators, $importedmodel->indicators); + $this->assertSame($this->modelobj->timesplitting, $importedmodel->timesplitting); + $this->assertSame($this->modelobj->predictionsprocessor, $importedmodel->predictionsprocessor); + } + + /** + * Test can export configuration + */ + public function test_can_export_configuration() { + $this->resetAfterTest(true); + + // No time splitting method. + $this->assertFalse($this->model->can_export_configuration()); + + $this->model->enable('\\core\\analytics\\time_splitting\\quarters'); + $this->assertTrue($this->model->can_export_configuration()); + + $this->model->update(true, [], false); + $this->assertFalse($this->model->can_export_configuration()); + + $statictarget = new test_static_target_shortname(); + $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max'); + $model = \core_analytics\model::create($statictarget, $indicators, '\\core\\analytics\\time_splitting\\quarters'); + $this->assertFalse($model->can_export_configuration()); + } + + /** + * Test export_config + */ + public function test_export_config() { + $this->resetAfterTest(true); + + $this->model->enable('\\core\\analytics\\time_splitting\\quarters'); + + $modelconfig = new \core_analytics\model_config($this->model); + $modeldata = $modelconfig->export(); + + $this->assertArrayHasKey('core', $modeldata->dependencies); + $this->assertInternalType('float', $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 = $modelconfig->export(); + + $this->assertCount(1, $modeldata->indicators); } /** diff --git a/lib/classes/component.php b/lib/classes/component.php index de199dce524..af9449e1461 100644 --- a/lib/classes/component.php +++ b/lib/classes/component.php @@ -1141,6 +1141,17 @@ $cache = '.var_export($cache, true).'; * @return string sha1 hash */ public static function get_all_versions_hash() { + return sha1(serialize(self::get_all_versions())); + } + + /** + * Returns hash of all versions including core and all plugins. + * + * This is relatively slow and not fully cached, use with care! + * + * @return array as (string)plugintype_pluginname => (int)version + */ + public static function get_all_versions() : array { global $CFG; self::init(); @@ -1174,7 +1185,7 @@ $cache = '.var_export($cache, true).'; } } - return sha1(serialize($versions)); + return $versions; } /**
{{#str}}analyticmodels, tool_analytics{{/str}}