From 349c44128db1608d6155a92cf5003f33124977a7 Mon Sep 17 00:00:00 2001 From: Ankit Agarwal Date: Tue, 5 Dec 2017 10:34:43 +0530 Subject: [PATCH 1/3] MDL-60944 analytics: Add base support for import / export --- .../analytics/classes/import_model_form.php | 47 ++++++++++++++ .../analytics/classes/output/models_list.php | 11 +++- admin/tool/analytics/importmodel.php | 57 ++++++++++++++++ .../tool/analytics/lang/en/tool_analytics.php | 6 ++ admin/tool/analytics/model.php | 26 +++++++- admin/tool/analytics/settings.php | 2 + analytics/classes/model.php | 65 +++++++++++++++++++ analytics/tests/model_test.php | 30 +++++++++ 8 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 admin/tool/analytics/classes/import_model_form.php create mode 100644 admin/tool/analytics/importmodel.php diff --git a/admin/tool/analytics/classes/import_model_form.php b/admin/tool/analytics/classes/import_model_form.php new file mode 100644 index 00000000000..ae0709e2a5a --- /dev/null +++ b/admin/tool/analytics/classes/import_model_form.php @@ -0,0 +1,47 @@ +. + +/** + * Model upload form. + * + * @package tool_analytics + * @copyright 2017 onwards Ankit Agarwal + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_analytics; +defined('MOODLE_INTERNAL') || die(); + + +/** + * Model upload form. + * + * @package tool_analytics + * @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 () { + $mform = $this->_form; + + $mform->addElement('header', 'settingsheader', get_string('analyticsimportmodel', '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')); + } +} \ No newline at end of file diff --git a/admin/tool/analytics/classes/output/models_list.php b/admin/tool/analytics/classes/output/models_list.php index 83e2755f05c..a0785df26c7 100644 --- a/admin/tool/analytics/classes/output/models_list.php +++ b/admin/tool/analytics/classes/output/models_list.php @@ -229,13 +229,22 @@ class models_list implements \renderable, \templatable { // Export training data. if (!$model->is_static() && $model->is_trained()) { - $urlparams['action'] = 'export'; + $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')); $actionsmenu->add($icon); } + // Export model. + 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', + get_string('exportmodel', 'tool_analytics')), get_string('exportmodel', 'tool_analytics')); + $actionsmenu->add($icon); + } + // Invalid analysables. $analyser = $model->get_analyser(['notimesplitting' => true]); if (!$analyser instanceof \core_analytics\local\analyser\sitewide) { diff --git a/admin/tool/analytics/importmodel.php b/admin/tool/analytics/importmodel.php new file mode 100644 index 00000000000..18d2e314a2b --- /dev/null +++ b/admin/tool/analytics/importmodel.php @@ -0,0 +1,57 @@ +. + +/** + * Import models tool frontend. + * + * @package tool_analytics + * @copyright 2017 onwards Ankit Agarwal + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once(__DIR__ . '/../../../config.php'); +require_once($CFG->libdir . '/adminlib.php'); + +admin_externalpage_setup('analyticsmodelimport', '', null, '', array('pagelayout' => 'report')); +echo $OUTPUT->header(); + +$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'); + } + } + echo $OUTPUT->single_button(new moodle_url("$CFG->wwwroot/$CFG->admin/tool/analytics/index.php"), + get_string('continue'), 'get'); +} else { + $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 1c3bba2305a..9844c58cf1d 100644 --- a/admin/tool/analytics/lang/en/tool_analytics.php +++ b/admin/tool/analytics/lang/en/tool_analytics.php @@ -26,9 +26,11 @@ $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'; +$string['classdoesnotexist'] = 'Class {$a} does not exist'; $string['clearpredictions'] = 'Clear predictions'; $string['clearmodelpredictions'] = 'Are you sure you want to clear all "{$a}" predictions?'; $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).'; @@ -42,6 +44,7 @@ $string['errorcantenablenotimesplitting'] = 'You need to select a time-splitting $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['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.'; @@ -51,6 +54,7 @@ $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['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'; @@ -67,6 +71,7 @@ $string['invalidanalysablestable'] = 'Invalid site analysable elements table'; $string['invalidprediction'] = 'Invalid to get predictions'; $string['invalidtraining'] = 'Invalid to train the model'; $string['loginfo'] = 'Log extra info'; +$string['missingmoodleversion'] = 'Imported file does not define a moodle version number'; $string['modelid'] = 'Model ID'; $string['modelinvalidanalysables'] = 'Invalid analysable elements for "{$a}" model'; $string['modelresults'] = '{$a} results'; @@ -92,6 +97,7 @@ $string['trainandpredictmodel'] = 'Training model and calculating predictions'; $string['trainingprocessfinished'] = 'Training process finished'; $string['trainingresults'] = 'Training results'; $string['trainmodels'] = 'Train models'; +$string['versionnotsame'] = 'Imported file was from a different moodle version ({$a->importedversion}) than the current one ({$a->version})'; $string['viewlog'] = 'Log'; $string['weeksenddateautomaticallyset'] = 'End date automatically set based on start date and the number of sections'; $string['weeksenddatedefault'] = 'End date automatically calculated from the course start date.'; diff --git a/admin/tool/analytics/model.php b/admin/tool/analytics/model.php index b70fc24070a..5e5f72a9529 100644 --- a/admin/tool/analytics/model.php +++ b/admin/tool/analytics/model.php @@ -23,6 +23,7 @@ */ require_once(__DIR__ . '/../../../config.php'); +require_once($CFG->libdir . '/dataformatlib.php'); $id = required_param('id', PARAM_INT); $action = required_param('action', PARAM_ALPHANUMEXT); @@ -57,8 +58,11 @@ switch ($action) { case 'disable': $title = get_string('disable'); break; - case 'export': - $title = get_string('export', 'tool_analytics'); + case 'exportdata': + $title = get_string('exporttrainingdata', 'tool_analytics'); + break; + case 'exportmodel': + $title = get_string('exportmodel', 'tool_analytics'); break; case 'clear': $title = get_string('clearpredictions', 'tool_analytics'); @@ -203,7 +207,7 @@ switch ($action) { echo $renderer->render_table($modellogstable); break; - case 'export': + case 'exportdata': if ($model->is_static() || !$model->is_trained()) { throw new moodle_exception('errornoexport', 'tool_analytics'); @@ -219,6 +223,22 @@ switch ($action) { send_file($file, $filename, null, 0, false, true); 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'); + } + @header("Content-type: text/json; charset=UTF-8"); + send_temp_file($tempfilename, $downloadfilename); + break; + case 'clear': confirm_sesskey(); diff --git a/admin/tool/analytics/settings.php b/admin/tool/analytics/settings.php index aad459a5023..c70405ed68f 100644 --- a/admin/tool/analytics/settings.php +++ b/admin/tool/analytics/settings.php @@ -26,3 +26,5 @@ 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/analytics/classes/model.php b/analytics/classes/model.php index fa6e91eae88..97c646032d0 100644 --- a/analytics/classes/model.php +++ b/analytics/classes/model.php @@ -385,6 +385,44 @@ class model { return $model; } + /** + * Creates a new model from json configuration. + * + * @param string $json json data. + * @return \core_analytics\model + */ + public static function create_from_json($jsondata) { + + \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"); + } + + // Target. + $target = $jsondata->target; + if (!class_exists($target)) { + throw new \moodle_exception('classdoesnotexist', 'tool_analytics', $target); + } + $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); + } + $indicators[] = \core_analytics\manager::get_indicator($indicator); + } + + // Timesplitting. + $timesplitting = $jsondata->timesplitting; + if (!class_exists($timesplitting)) { + throw new \moodle_exception('classdoesnotexist', 'tool_analytics', $timesplitting); + } + + return self::create($target, $indicators, $timesplitting); + } + /** * Does this model exist? * @@ -1379,6 +1417,33 @@ class model { return $data; } + /** + * Exports the model data as JSON. + * + * @return string JSON encoded data. + */ + public function export_as_json() { + global $CFG; + + $data = new \stdClass(); + $data->target = $this->get_target()->get_id(); + + + 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'); + } + + $data->indicators = []; + foreach ($this->get_indicators() as $indicator) { + $data->indicators[] = $indicator->get_id(); + } + $data->moodleversion = $CFG->version; + return json_encode($data); + } + /** * Returns the model logs data. * diff --git a/analytics/tests/model_test.php b/analytics/tests/model_test.php index 21009bace9b..a4e18d411dd 100644 --- a/analytics/tests/model_test.php +++ b/analytics/tests/model_test.php @@ -317,6 +317,36 @@ class analytics_model_testcase extends advanced_testcase { $this->assertLessThanOrEqual(2, $DB->count_records('analytics_used_analysables', $params)); } + /** + * Test export_as_json() API. + */ + public function test_export_as_json() { + global $CFG; + $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); + } + + /** + * Test export_from_json() API. + */ + public function test_create_from_json() { + global $CFG; + $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); + } + /** * Generates a model log record. */ 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 2/3] 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; } /** From c70a7194f41e5d3769355856da17ec091bc311b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Monlla=C3=B3?= Date: Fri, 1 Feb 2019 06:52:42 +0100 Subject: [PATCH 3/3] MDL-60944 analytics: Include trained ML models --- .../classes/output/form/import_model.php | 2 +- .../tool/analytics/classes/output/helper.php | 2 +- admin/tool/analytics/createmodel.php | 7 +- admin/tool/analytics/importmodel.php | 10 +- .../tool/analytics/lang/en/tool_analytics.php | 8 +- admin/tool/analytics/model.php | 7 +- analytics/classes/model.php | 92 +++---- analytics/classes/model_config.php | 225 ++++++++++++------ analytics/classes/packable.php | 57 +++++ analytics/tests/model_test.php | 50 ++-- analytics/tests/prediction_test.php | 124 +++++++--- lang/en/analytics.php | 8 + lib/mlbackend/php/classes/processor.php | 79 +++++- lib/mlbackend/python/classes/processor.php | 74 +++++- 14 files changed, 513 insertions(+), 232 deletions(-) create mode 100644 analytics/classes/packable.php diff --git a/admin/tool/analytics/classes/output/form/import_model.php b/admin/tool/analytics/classes/output/form/import_model.php index 8c17e395797..1b4c3756bb1 100644 --- a/admin/tool/analytics/classes/output/form/import_model.php +++ b/admin/tool/analytics/classes/output/form/import_model.php @@ -45,7 +45,7 @@ class import_model extends \moodleform { $mform->addElement('header', 'settingsheader', get_string('importmodel', 'tool_analytics')); - $mform->addElement('filepicker', 'modelfile', get_string('file'), null, ['accepted_types' => '.json']); + $mform->addElement('filepicker', 'modelfile', get_string('file'), null, ['accepted_types' => '.zip']); $mform->addRule('modelfile', null, 'required'); $mform->addElement('advcheckbox', 'ignoreversionmismatches', get_string('ignoreversionmismatches', 'tool_analytics'), diff --git a/admin/tool/analytics/classes/output/helper.php b/admin/tool/analytics/classes/output/helper.php index 59b33d838e8..6543022315d 100644 --- a/admin/tool/analytics/classes/output/helper.php +++ b/admin/tool/analytics/classes/output/helper.php @@ -64,7 +64,7 @@ class helper { * * @param string $title * @param \moodle_url $url - * @param \context|false $context Defaults to context_system + * @param \context|null $context Defaults to context_system * @return null */ public static function set_navbar(string $title, \moodle_url $url, ?\context $context = null) { diff --git a/admin/tool/analytics/createmodel.php b/admin/tool/analytics/createmodel.php index 8ee0f4d9d37..bf1701c201e 100644 --- a/admin/tool/analytics/createmodel.php +++ b/admin/tool/analytics/createmodel.php @@ -54,7 +54,10 @@ if ($mform->is_cancelled()) { // Converting option names to class names. $targetclass = \tool_analytics\output\helper::option_to_class($data->target); - $target = \core_analytics\manager::get_target($targetclass); + if (empty($targets[$targetclass])) { + throw new \moodle_exception('errorinvalidtarget', 'analytics', '', $targetclass); + } + $target = $targets[$targetclass]; $indicators = array(); foreach ($data->indicators as $indicator) { @@ -88,4 +91,4 @@ if ($mform->is_cancelled()) { echo $OUTPUT->header(); $mform->display(); -echo $OUTPUT->footer(); \ No newline at end of file +echo $OUTPUT->footer(); diff --git a/admin/tool/analytics/importmodel.php b/admin/tool/analytics/importmodel.php index 250b14f4ad5..eedeaa0531a 100644 --- a/admin/tool/analytics/importmodel.php +++ b/admin/tool/analytics/importmodel.php @@ -40,19 +40,15 @@ if ($form->is_cancelled()) { $modelconfig = new \core_analytics\model_config(); - $json = $form->get_file_content('modelfile'); + $zipfilepath = $form->save_temp_file('modelfile'); - if ($error = $modelconfig->check_json_data($json)) { - // The provided file is not ok. - redirect($url, $error, 0, \core\output\notification::NOTIFY_ERROR); - } + list ($modeldata, $unused) = $modelconfig->extract_import_contents($zipfilepath); - $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); + \core_analytics\model::import_model($zipfilepath); redirect($returnurl, get_string('importedsuccessfully', 'tool_analytics'), 0, \core\output\notification::NOTIFY_SUCCESS); diff --git a/admin/tool/analytics/lang/en/tool_analytics.php b/admin/tool/analytics/lang/en/tool_analytics.php index 267f96a32df..580d7b8b18c 100644 --- a/admin/tool/analytics/lang/en/tool_analytics.php +++ b/admin/tool/analytics/lang/en/tool_analytics.php @@ -37,21 +37,15 @@ $string['clievaluationandpredictions'] = 'A scheduled task iterates through enab $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['deletemodelconfirmation'] = 'Are you sure you want to delete "{$a}"? These changes can not be reverted.'; $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['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.'; diff --git a/admin/tool/analytics/model.php b/admin/tool/analytics/model.php index f385410e41a..23d84540255 100644 --- a/admin/tool/analytics/model.php +++ b/admin/tool/analytics/model.php @@ -229,10 +229,9 @@ switch ($action) { break; case 'exportmodel': - $downloadfilename = 'model-config.' . $model->get_id() . '.' . microtime() . '.json'; - $filepath = $model->export_config($downloadfilename); - @header("Content-type: text/json; charset=UTF-8"); - send_temp_file($filepath, $downloadfilename); + $zipfilename = 'model-' . $model->get_unique_id() . '-' . microtime(false) . '.zip'; + $zipfilepath = $model->export_model($zipfilename); + send_temp_file($zipfilepath, $zipfilename); break; case 'clear': diff --git a/analytics/classes/model.php b/analytics/classes/model.php index 839b7e2beec..3b6c6e40c53 100644 --- a/analytics/classes/model.php +++ b/analytics/classes/model.php @@ -338,12 +338,12 @@ 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. + * @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 = false) { + $timesplittingid = false, $processor = null) { global $USER, $DB; \core_analytics\manager::check_can_manage_models(); @@ -386,59 +386,6 @@ class model { return $model; } - /** - * Creates a new model from import configuration. - * - * 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_import(\stdClass $modeldata, ?bool $skipcheckdependencies = false) : ?\core_analytics\model { - - \core_analytics\manager::check_can_manage_models(); - - if (!$skipcheckdependencies) { - $modelconfig = new model_config(); - if ($error = $modelconfig->check_dependencies($modeldata, false)) { - return null; - } - } - - // 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.'); - } - - // Indicators. - $indicators = []; - 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[] = $indicator; - } - - 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, $modeldata->timesplitting, $modeldata->processor); - } - /** * Does this model exist? * @@ -1363,7 +1310,7 @@ class model { * @param bool $onlymodelid Preference over $subdirs * @return string */ - protected function get_output_dir($subdirs = array(), $onlymodelid = false) { + public function get_output_dir($subdirs = array(), $onlymodelid = false) { global $CFG; $subdirstr = ''; @@ -1412,7 +1359,7 @@ class model { } /** - * Exports the model data. + * Exports the model data for displaying it in a template. * * @return \stdClass */ @@ -1435,19 +1382,34 @@ class model { } /** - * Exports the model data as a JSON file. + * Exports the model data to a zip file. * - * @param string $downloadfilename Download file name. - * @return string The filepath + * @param string $zipfilename + * @return string Zip file path */ - public function export_config(string $downloadfilename) : string { - global $CFG; + public function export_model(string $zipfilename) : string { \core_analytics\manager::check_can_manage_models(); $modelconfig = new model_config($this); - $modeldata = $modelconfig->export(); - return $modelconfig->export_to_file($modeldata, $downloadfilename); + return $modelconfig->export($zipfilename); + } + + /** + * 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); } /** diff --git a/analytics/classes/model_config.php b/analytics/classes/model_config.php index 162fc15751d..2cb32cc22f4 100644 --- a/analytics/classes/model_config.php +++ b/analytics/classes/model_config.php @@ -40,6 +40,11 @@ class model_config { */ private $model = null; + /** + * The name of the file where config is held. + */ + const CONFIG_FILE_NAME = 'model-config.json'; + /** * Constructor. * @@ -50,100 +55,95 @@ class model_config { } /** - * Exports a model to a temp file using the provided file name. + * Exports a model to a zip using the provided file name. * - * @return \stdClass + * @param string $zipfilename + * @return string */ - public function export() : \stdClass { + public function export(string $zipfilename) : string { if (!$this->model) { throw new \coding_exception('No model object provided.'); } if (!$this->model->can_export_configuration()) { - throw new \moodle_exception('errornoexportconfigrequirements', 'tool_analytics'); + throw new \moodle_exception('errornoexportconfigrequirements', 'analytics'); } - $versions = \core_component::get_all_versions(); + $zip = new \zip_packer(); + $zipfiles = []; - $data = new \stdClass(); + // Model config in JSON. + $modeldata = $this->export_model_data(); - // Target. - $data->target = $this->model->get_target()->get_id(); - $requiredclasses[] = $data->target; + $exporttmpdir = make_request_directory('analyticsexport'); + $jsonfilepath = $exporttmpdir . DIRECTORY_SEPARATOR . 'model-config.json'; + if (!file_put_contents($jsonfilepath, json_encode($modeldata))) { + print_error('errornoexportconfig', 'analytics'); + } + $zipfiles[self::CONFIG_FILE_NAME] = $jsonfilepath; - // 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; + // ML backend. + if ($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; + } } - 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]; - } + $zipfilepath = $exporttmpdir . DIRECTORY_SEPARATOR . $zipfilename; + $zip->archive_to_pathname($zipfiles, $zipfilepath); - return $data; + return $zipfilepath; } /** - * Packages the configuration of a model into a .json file. + * Imports the provided model configuration into a new model. * - * @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. + * Note that this method assumes that self::check_dependencies has already been called. * - * @param string $json A json string. - * @return string|null Error string or null if all good. + * @param string $zipfilepath Path to the zip file to import + * @return \core_analytics\model */ - public function check_json_data(string $json) : ?string { + public function import(string $zipfilepath) : \core_analytics\model { - if (!$modeldata = json_decode($json)) { - return get_string('errorimport', 'tool_analytics'); + 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(); } - if (empty($modeldata->target) || empty($modeldata->timesplitting) || empty($modeldata->indicators)) { - return get_string('errorimport', 'tool_analytics'); - } - - return null; + return $model; } /** * Check that the provided model configuration can be deployed in this site. * - * @param \stdClass $importmodel + * @param \stdClass $modeldata * @param bool $ignoreversionmismatches * @return string|null Error string or null if all good. */ - public function check_dependencies(\stdClass $importmodel, bool $ignoreversionmismatches) : ?string { + public function check_dependencies(\stdClass $modeldata, bool $ignoreversionmismatches) : ?string { $siteversions = \core_component::get_all_versions(); @@ -153,7 +153,7 @@ class model_config { $missingclasses = []; // We first check that this site has the required dependencies and the required versions. - foreach ($importmodel->dependencies as $component => $importversion) { + foreach ($modeldata->dependencies as $component => $importversion) { if (empty($siteversions[$component])) { @@ -177,44 +177,42 @@ class model_config { } } - // Checking that the each of the components is available. - if (!$target = manager::get_target($importmodel->target)) { - $missingclasses[] = $importmodel->target; + // Checking that each of the components is available. + if (!$target = manager::get_target($modeldata->target)) { + $missingclasses[] = $modeldata->target; } - if (!$timesplitting = manager::get_time_splitting($importmodel->timesplitting)) { - $missingclasses[] = $importmodel->timesplitting; + if (!$timesplitting = manager::get_time_splitting($modeldata->timesplitting)) { + $missingclasses[] = $modeldata->timesplitting; } // Indicators. - $indicators = []; - foreach ($importmodel->indicators as $indicatorclass) { + foreach ($modeldata->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)) { + if (!empty($modeldata->processor)) { + if (!$processor = \core_analytics\manager::get_predictions_processor($modeldata->processor, false)) { $missingclasses[] = $indicatorclass; } } if (!empty($missingcomponents)) { - return get_string('errorimportmissingcomponents', 'tool_analytics', join(', ', $missingcomponents)); + return get_string('errorimportmissingcomponents', 'analytics', join(', ', $missingcomponents)); } if (!empty($versionmismatches)) { - return get_string('errorimportversionmismatches', 'tool_analytics', implode(', ', $versionmismatches)); + return get_string('errorimportversionmismatches', 'analytics', implode(', ', $versionmismatches)); } if (!empty($missingclasses)) { $a = (object)[ 'missingclasses' => implode(', ', $missingclasses), - 'dependencyversions' => implode(', ', $dependencyversions) ]; - return get_string('errorimportmissingclasses', 'tool_analytics', $a); + return get_string('errorimportmissingclasses', 'analytics', $a); } // No issues found. @@ -248,4 +246,81 @@ class model_config { 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('analyticsimport' . microtime(false)); + + $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; + } } diff --git a/analytics/classes/packable.php b/analytics/classes/packable.php new file mode 100644 index 00000000000..1d26f65f479 --- /dev/null +++ b/analytics/classes/packable.php @@ -0,0 +1,57 @@ +. + +/** + * 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; +} diff --git a/analytics/tests/model_test.php b/analytics/tests/model_test.php index 782c916c899..19ceb6166a0 100644 --- a/analytics/tests/model_test.php +++ b/analytics/tests/model_test.php @@ -337,36 +337,24 @@ class analytics_model_testcase extends advanced_testcase { } /** - * Test export_from_json() API. + * Test that import_model import models' configurations. */ - public function test_create_from_import() { + public function test_import_model_config() { $this->resetAfterTest(true); $this->model->enable('\\core\\analytics\\time_splitting\\quarters'); + $zipfilepath = $this->model->export_model('yeah-config.zip'); $this->modelobj = $this->model->get_model_obj(); - $modelconfig = new \core_analytics\model_config($this->model); - $modeldata = $modelconfig->export(); - $importedmodel = \core_analytics\model::create_from_import($modeldata)->get_model_obj(); + $importedmodelobj = \core_analytics\model::import_model($zipfilepath)->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->assertSame($this->modelobj->target, $importedmodelobj->target); + $this->assertSame($this->modelobj->indicators, $importedmodelobj->indicators); + $this->assertSame($this->modelobj->timesplitting, $importedmodelobj->timesplitting); - $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); + $predictionsprocessor = $this->model->get_predictions_processor(); + $this->assertSame('\\' . get_class($predictionsprocessor), $importedmodelobj->predictionsprocessor); } /** @@ -399,7 +387,11 @@ class analytics_model_testcase extends advanced_testcase { $this->model->enable('\\core\\analytics\\time_splitting\\quarters'); $modelconfig = new \core_analytics\model_config($this->model); - $modeldata = $modelconfig->export(); + + $method = new ReflectionMethod('\\core_analytics\\model_config', 'export_model_data'); + $method->setAccessible(true); + + $modeldata = $method->invoke($modelconfig); $this->assertArrayHasKey('core', $modeldata->dependencies); $this->assertInternalType('float', $modeldata->dependencies['core']); @@ -409,7 +401,8 @@ class analytics_model_testcase extends advanced_testcase { $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max'); $this->model->update(true, $indicators, false); - $modeldata = $modelconfig->export(); + + $modeldata = $method->invoke($modelconfig); $this->assertCount(1, $modeldata->indicators); } @@ -443,17 +436,6 @@ class analytics_model_testcase extends advanced_testcase { */ class testable_model extends \core_analytics\model { - /** - * get_output_dir - * - * @param array $subdirs - * @param bool $onlymodelid - * @return string - */ - public function get_output_dir($subdirs = array(), $onlymodelid = false) { - return parent::get_output_dir($subdirs, $onlymodelid); - } - /** * init_analyser * diff --git a/analytics/tests/prediction_test.php b/analytics/tests/prediction_test.php index 7d74868013e..18582c32c1c 100644 --- a/analytics/tests/prediction_test.php +++ b/analytics/tests/prediction_test.php @@ -119,23 +119,9 @@ class core_analytics_prediction_testcase extends advanced_testcase { $this->setAdminuser(); set_config('enabled_stores', 'logstore_standard', 'tool_log'); - $ncourses = 10; - // Generate training data. - $params = array( - 'startdate' => mktime(0, 0, 0, 10, 24, 2015), - 'enddate' => mktime(0, 0, 0, 2, 24, 2016), - ); - for ($i = 0; $i < $ncourses; $i++) { - $name = 'a' . random_string(10); - $courseparams = array('shortname' => $name, 'fullname' => $name) + $params; - $this->getDataGenerator()->create_course($courseparams); - } - for ($i = 0; $i < $ncourses; $i++) { - $name = 'b' . random_string(10); - $courseparams = array('shortname' => $name, 'fullname' => $name) + $params; - $this->getDataGenerator()->create_course($courseparams); - } + $ncourses = 10; + $this->generate_courses($ncourses); // We repeat the test for all prediction processors. $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false); @@ -171,6 +157,10 @@ class core_analytics_prediction_testcase extends advanced_testcase { $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); @@ -280,6 +270,67 @@ class core_analytics_prediction_testcase extends advanced_testcase { return $this->add_prediction_processors($cases); } + /** + * test_ml_export_import + * + * @param string $predictionsprocessorclass The class name + * @dataProvider provider_ml_export_import + */ + public function test_ml_export_import($predictionsprocessorclass) { + + $this->resetAfterTest(true); + $this->setAdminuser(); + set_config('enabled_stores', 'logstore_standard', 'tool_log'); + + // Generate training data. + $ncourses = 10; + $this->generate_courses($ncourses); + + // We repeat the test for all prediction processors. + $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false); + if ($predictionsprocessor->is_ready() !== true) { + $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.'); + } + + $model = $this->add_perfect_model(); + $model->update(true, false, '\core\analytics\time_splitting\quarters', get_class($predictionsprocessor)); + + $model->train(); + + $this->generate_courses(10, ['visible' => 0]); + + $originalresults = $model->predict(); + + $zipfilename = 'model-zip-' . microtime() . '.zip'; + $zipfilepath = $model->export_model($zipfilename); + + $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); + } + + set_config('enabled_stores', '', 'tool_log'); + get_log_manager(true); + } + + /** + * provider_ml_export_import + * + * @return array + */ + public function provider_ml_export_import() { + $cases = [ + 'case' => [], + ]; + + // We need to test all system prediction processors. + return $this->add_prediction_processors($cases); + } /** * Test the system classifiers returns. * @@ -400,20 +451,7 @@ class core_analytics_prediction_testcase extends advanced_testcase { } // Generate training data. - $params = array( - '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); - $params = array('shortname' => $name, 'fullname' => $name) + $params; - $this->getDataGenerator()->create_course($params); - } - for ($i = 0; $i < $ncourses; $i++) { - $name = 'b' . random_string(10); - $params = array('shortname' => $name, 'fullname' => $name) + $params; - $this->getDataGenerator()->create_course($params); - } + $this->generate_courses($ncourses); // We repeat the test for all prediction processors. $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false); @@ -579,6 +617,32 @@ class core_analytics_prediction_testcase extends advanced_testcase { return new \core_analytics\model($model->get_id()); } + /** + * Generates $ncourses courses + * + * @param int $ncourses The number of courses to be generated. + * @param array $params Course params + * @return null + */ + protected function generate_courses($ncourses, array $params = []) { + + $params = $params + [ + 'startdate' => mktime(0, 0, 0, 10, 24, 2015), + 'enddate' => mktime(0, 0, 0, 2, 24, 2016), + ]; + + for ($i = 0; $i < $ncourses; $i++) { + $name = 'a' . random_string(10); + $courseparams = array('shortname' => $name, 'fullname' => $name) + $params; + $this->getDataGenerator()->create_course($courseparams); + } + for ($i = 0; $i < $ncourses; $i++) { + $name = 'b' . random_string(10); + $courseparams = array('shortname' => $name, 'fullname' => $name) + $params; + $this->getDataGenerator()->create_course($courseparams); + } + } + /** * add_prediction_processors * diff --git a/lang/en/analytics.php b/lang/en/analytics.php index bc843f8dc15..0604129df39 100644 --- a/lang/en/analytics.php +++ b/lang/en/analytics.php @@ -38,8 +38,16 @@ $string['erroralreadypredict'] = 'File {$a} has already been used to generate pr $string['errorcannotreaddataset'] = 'Dataset file {$a} can not be read'; $string['errorcannotwritedataset'] = 'Dataset file {$a} cannot be written'; $string['errorendbeforestart'] = 'The end date ({$a}) is before the course start date.'; +$string['errorexportmodelresult'] = 'The machine learning model can not be exported.'; +$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['errorinvalidindicator'] = 'Invalid {$a} indicator'; +$string['errorinvalidtarget'] = 'Invalid {$a} target'; $string['errorinvalidtimesplitting'] = 'Invalid time splitting; please ensure you add the class fully qualified class name.'; +$string['errornoexportconfig'] = 'There was a problem exporting the model configuration.'; +$string['errornoexportconfigrequirements'] = 'Only non static models with timeplitting methods can be exported.'; $string['errornoindicators'] = 'This model does not have any indicators.'; $string['errornopredictresults'] = 'No results returned from the predictions processor. Check the output directory contents for more information.'; $string['errornotimesplittings'] = 'This model does not have any time-splitting method.'; diff --git a/lib/mlbackend/php/classes/processor.php b/lib/mlbackend/php/classes/processor.php index cfba5933860..445735031c2 100644 --- a/lib/mlbackend/php/classes/processor.php +++ b/lib/mlbackend/php/classes/processor.php @@ -38,7 +38,7 @@ use Phpml\ModelManager; * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class processor implements \core_analytics\classifier, \core_analytics\regressor { +class processor implements \core_analytics\classifier, \core_analytics\regressor, \core_analytics\packable { /** * Size of training / prediction batches. @@ -103,8 +103,7 @@ class processor implements \core_analytics\classifier, \core_analytics\regressor */ public function train_classification($uniqueid, \stored_file $dataset, $outputdir) { - // Output directory is already unique to the model. - $modelfilepath = $outputdir . DIRECTORY_SEPARATOR . self::MODEL_FILENAME; + $modelfilepath = $this->get_model_filepath($outputdir); $modelmanager = new ModelManager(); @@ -175,8 +174,7 @@ class processor implements \core_analytics\classifier, \core_analytics\regressor */ public function classify($uniqueid, \stored_file $dataset, $outputdir) { - // Output directory is already unique to the model. - $modelfilepath = $outputdir . DIRECTORY_SEPARATOR . self::MODEL_FILENAME; + $modelfilepath = $this->get_model_filepath($outputdir); if (!file_exists($modelfilepath)) { throw new \moodle_exception('errorcantloadmodel', 'mlbackend_php', '', $modelfilepath); @@ -424,6 +422,77 @@ class processor implements \core_analytics\classifier, \core_analytics\regressor throw new \coding_exception('This predictor does not support regression yet.'); } + /** + * 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 { + + $modelfilepath = $this->get_model_filepath($modeldir); + + if (!file_exists($modelfilepath)) { + throw new \moodle_exception('errorexportmodelresult', 'analytics'); + } + + // We can use the actual $modeldir as the directory is not modified during export, just copied into a zip. + return $modeldir; + } + + /** + * 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 { + + $importmodelfilepath = $this->get_model_filepath($importdir); + $modelfilepath = $this->get_model_filepath($modeldir); + + $modelmanager = new ModelManager(); + + // Copied from ModelManager::restoreFromFile to validate the serialised contents + // before restoring them. + $importconfig = file_get_contents($importmodelfilepath); + + // Clean stuff like function calls. + $importconfig = preg_replace('/[^a-zA-Z0-9\{\}%\.\*\;\,\:\"\-\0\\\]/', '', $importconfig); + + $object = unserialize($importconfig, + ['allowed_classes' => ['Phpml\\Classification\\Linear\\LogisticRegression']]); + if (!$object) { + return false; + } + + if (get_class($object) == '__PHP_Incomplete_Class') { + return false; + } + + $classifier = $modelmanager->restoreFromFile($importmodelfilepath); + + // This would override any previous classifier. + $modelmanager->saveToFile($classifier, $modelfilepath); + + return true; + } + + /** + * Returns the path to the serialised model file in the provided directory. + * + * @param string $modeldir The model directory + * @return string The model file + */ + protected function get_model_filepath(string $modeldir) : string { + // Output directory is already unique to the model. + return $modeldir . DIRECTORY_SEPARATOR . self::MODEL_FILENAME; + } + /** * Returns the Phi correlation coefficient. * diff --git a/lib/mlbackend/python/classes/processor.php b/lib/mlbackend/python/classes/processor.php index 864f23e0491..3d77ff4ea32 100644 --- a/lib/mlbackend/python/classes/processor.php +++ b/lib/mlbackend/python/classes/processor.php @@ -33,7 +33,7 @@ defined('MOODLE_INTERNAL') || die(); * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class processor implements \core_analytics\classifier, \core_analytics\regressor { +class processor implements \core_analytics\classifier, \core_analytics\regressor, \core_analytics\packable { /** * The required version of the python package that performs all calculations. @@ -263,6 +263,78 @@ class processor implements \core_analytics\classifier, \core_analytics\regresso return $resultobj; } + /** + * 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 { + + // We include an exporttmpdir as we want to be sure that the file is not deleted after the + // python process finishes. + $exporttmpdir = make_request_directory('mlbackend_python_export'); + + $cmd = "{$this->pathtopython} -m moodlemlbackend.export " . + escapeshellarg($uniqueid) . ' ' . + escapeshellarg($modeldir) . ' ' . + escapeshellarg($exporttmpdir); + + if (!PHPUNIT_TEST && CLI_SCRIPT) { + debugging($cmd, DEBUG_DEVELOPER); + } + + $output = null; + $exitcode = null; + $exportdir = exec($cmd, $output, $exitcode); + + if ($exitcode != 0) { + throw new \moodle_exception('errorexportmodelresult', 'analytics'); + } + + if (!$exportdir) { + throw new \moodle_exception('errorexportmodelresult', 'analytics'); + } + + return $exportdir; + } + + /** + * 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 { + + $cmd = "{$this->pathtopython} -m moodlemlbackend.import " . + escapeshellarg($uniqueid) . ' ' . + escapeshellarg($modeldir) . ' ' . + escapeshellarg($importdir); + + if (!PHPUNIT_TEST && CLI_SCRIPT) { + debugging($cmd, DEBUG_DEVELOPER); + } + + $output = null; + $exitcode = null; + $success = exec($cmd, $output, $exitcode); + + if ($exitcode != 0) { + throw new \moodle_exception('errorimportmodelresult', 'analytics'); + } + + if (!$success) { + throw new \moodle_exception('errorimportmodelresult', 'analytics'); + } + + return $success; + } + /** * Train this processor regression model using the provided supervised learning dataset. *
{{#str}}analyticmodels, tool_analytics{{/str}}