diff --git a/admin/settings/plugins.php b/admin/settings/plugins.php index 925829c5605..6b3848ba262 100644 --- a/admin/settings/plugins.php +++ b/admin/settings/plugins.php @@ -175,6 +175,13 @@ if ($hassiteconfig) { $plugin->load_settings($ADMIN, 'antivirussettings', $hassiteconfig); } + // Machine learning backend plugins. + $ADMIN->add('modules', new admin_category('mlbackendsettings', new lang_string('mlbackendsettings', 'admin'))); + $plugins = core_plugin_manager::instance()->get_plugins_of_type('mlbackend'); + foreach ($plugins as $plugin) { + $plugin->load_settings($ADMIN, 'mlbackendsettings', $hassiteconfig); + } + /// License types $ADMIN->add('modules', new admin_category('licensesettings', new lang_string('licenses'))); $temp = new admin_settingpage('managelicenses', new lang_string('managelicenses', 'admin')); diff --git a/analytics/classes/manager.php b/analytics/classes/manager.php index 699d22b7727..00a3d580f38 100644 --- a/analytics/classes/manager.php +++ b/analytics/classes/manager.php @@ -48,7 +48,7 @@ class manager { /** * @var \core_analytics\predictor[] */ - protected static $predictionprocessors = null; + protected static $predictionprocessors = []; /** * @var \core_analytics\local\target\base[] @@ -213,6 +213,14 @@ class manager { return $predictionprocessors; } + /** + * Resets the cached prediction processors. + * @return null + */ + public static function reset_prediction_processors() { + self::$predictionprocessors = []; + } + /** * Returns the name of the provided predictions processor. * diff --git a/analytics/classes/model.php b/analytics/classes/model.php index 9632347ee2c..2b85ef03b2c 100644 --- a/analytics/classes/model.php +++ b/analytics/classes/model.php @@ -539,7 +539,7 @@ class model { debugging('Prediction processor ' . $predictorname . ' is not ready to be used. Model ' . $this->model->id . ' could not be deleted.'); } else { - $predictor->delete_output_dir($this->get_output_dir(array(), true)); + $predictor->delete_output_dir($this->get_output_dir(array(), true), $this->get_unique_id()); } $DB->delete_records('analytics_models', array('id' => $this->model->id)); diff --git a/analytics/classes/predictor.php b/analytics/classes/predictor.php index 4dcb4913b33..bb90061305f 100644 --- a/analytics/classes/predictor.php +++ b/analytics/classes/predictor.php @@ -70,8 +70,9 @@ interface predictor { * can only be named 'execution', 'evaluation' or 'testing'. * * @param string $modeloutputdir The model directory id (parent of all model versions subdirectories). + * @param string $uniqueid * @return null */ - public function delete_output_dir($modeloutputdir); + public function delete_output_dir($modeloutputdir, $uniqueid); } diff --git a/analytics/tests/prediction_test.php b/analytics/tests/prediction_test.php index c3aeab3680b..8f8bca1da0f 100644 --- a/analytics/tests/prediction_test.php +++ b/analytics/tests/prediction_test.php @@ -17,6 +17,14 @@ /** * Unit tests for evaluation, training and prediction. * + * NOTE: in order to execute this test using a separate server for the + * python ML backend you need to define these variables in your config.php file: + * + * define('TEST_MLBACKEND_PYTHON_HOST', '127.0.0.1'); + * define('TEST_MLBACKEND_PYTHON_PORT', 5000); + * define('TEST_MLBACKEND_PYTHON_USERNAME', 'default'); + * define('TEST_MLBACKEND_PYTHON_PASSWORD', 'sshhhh'); + * * @package core_analytics * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later @@ -109,12 +117,18 @@ class core_analytics_prediction_testcase extends advanced_testcase { * @param int $predictedrangeindex * @param int $nranges * @param string $predictionsprocessorclass + * @param array $forcedconfig * @return void */ - public function test_ml_training_and_prediction($timesplittingid, $predictedrangeindex, $nranges, $predictionsprocessorclass) { + public function test_ml_training_and_prediction($timesplittingid, $predictedrangeindex, $nranges, $predictionsprocessorclass, + $forcedconfig) { global $DB; $this->resetAfterTest(true); + + $this->set_forced_config($forcedconfig); + $predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass); + $this->setAdminuser(); set_config('enabled_stores', 'logstore_standard', 'tool_log'); @@ -122,13 +136,8 @@ class core_analytics_prediction_testcase extends advanced_testcase { $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, $timesplittingid, get_class($predictionsprocessor)); // No samples trained yet. @@ -250,6 +259,17 @@ class core_analytics_prediction_testcase extends advanced_testcase { $this->assertCount(2, $fs->get_directory_files(\context_system::instance()->id, 'analytics', \core_analytics\dataset_manager::UNLABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false)); + // Confirm that the files associated to the model are deleted on clear and on delete. The ML backend deletion + // processes will be triggered by these actions and any exception there would result in a failed test. + $model->clear(); + $this->assertEquals(0, $DB->count_records('analytics_used_files', + array('modelid' => $model->get_id(), 'action' => 'trained'))); + $this->assertCount(0, $fs->get_directory_files(\context_system::instance()->id, 'analytics', + \core_analytics\dataset_manager::LABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false)); + $this->assertCount(0, $fs->get_directory_files(\context_system::instance()->id, 'analytics', + \core_analytics\dataset_manager::UNLABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false)); + $model->delete(); + set_config('enabled_stores', '', 'tool_log'); get_log_manager(true); } @@ -273,11 +293,15 @@ class core_analytics_prediction_testcase extends advanced_testcase { * test_ml_export_import * * @param string $predictionsprocessorclass The class name + * @param array $forcedconfig * @dataProvider provider_ml_processors */ - public function test_ml_export_import($predictionsprocessorclass) { - + public function test_ml_export_import($predictionsprocessorclass, $forcedconfig) { $this->resetAfterTest(true); + + $this->set_forced_config($forcedconfig); + $predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass); + $this->setAdminuser(); set_config('enabled_stores', 'logstore_standard', 'tool_log'); @@ -285,13 +309,8 @@ class core_analytics_prediction_testcase extends advanced_testcase { $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(); @@ -355,15 +374,14 @@ class core_analytics_prediction_testcase extends advanced_testcase { * @param int $nsamples * @param int $classes * @param string $predictionsprocessorclass + * @param array $forcedconfig * @return void */ - public function test_ml_classifiers_return($success, $nsamples, $classes, $predictionsprocessorclass) { + public function test_ml_classifiers_return($success, $nsamples, $classes, $predictionsprocessorclass, $forcedconfig) { $this->resetAfterTest(); - $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false); - if ($predictionsprocessor->is_ready() !== true) { - $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.'); - } + $this->set_forced_config($forcedconfig); + $predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass); if ($nsamples % count($classes) != 0) { throw new \coding_exception('The number of samples should be divisible by the number of classes'); @@ -396,7 +414,7 @@ class core_analytics_prediction_testcase extends advanced_testcase { // Training should work correctly if at least 1 sample of each class is included. $dir = make_request_directory(); - $result = $predictionsprocessor->train_classification('whatever', $dataset, $dir); + $result = $predictionsprocessor->train_classification('whatever' . microtime(), $dataset, $dir); switch ($success) { case 'yes': @@ -441,16 +459,19 @@ class core_analytics_prediction_testcase extends advanced_testcase { * @dataProvider provider_test_multi_classifier * @param string $timesplittingid * @param string $predictionsprocessorclass + * @param array|null $forcedconfig * @throws coding_exception * @throws moodle_exception */ - public function test_ml_multi_classifier($timesplittingid, $predictionsprocessorclass) { + public function test_ml_multi_classifier($timesplittingid, $predictionsprocessorclass, $forcedconfig) { global $DB; $this->resetAfterTest(true); $this->setAdminuser(); set_config('enabled_stores', 'logstore_standard', 'tool_log'); + $this->set_forced_config($forcedconfig); + $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false); if ($predictionsprocessor->is_ready() !== true) { $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.'); @@ -483,6 +504,9 @@ class core_analytics_prediction_testcase extends advanced_testcase { // The range index is not important here, both ranges prediction will be the same. $this->assertEquals($correct[$sampleid], $predictiondata->prediction); } + + set_config('enabled_stores', '', 'tool_log'); + get_log_manager(true); } /** @@ -508,10 +532,16 @@ class core_analytics_prediction_testcase extends advanced_testcase { * @param int $ncourses * @param array $expected * @param string $predictionsprocessorclass + * @param array $forcedconfig * @return void */ - public function test_ml_evaluation_configuration($modelquality, $ncourses, $expected, $predictionsprocessorclass) { + public function test_ml_evaluation_configuration($modelquality, $ncourses, $expected, $predictionsprocessorclass, + $forcedconfig) { $this->resetAfterTest(true); + + $this->set_forced_config($forcedconfig); + $predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass); + $this->setAdminuser(); set_config('enabled_stores', 'logstore_standard', 'tool_log'); @@ -530,12 +560,6 @@ class core_analytics_prediction_testcase extends advanced_testcase { // Generate training data. $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->update(false, false, false, get_class($predictionsprocessor)); $results = $model->evaluate(); @@ -563,10 +587,15 @@ class core_analytics_prediction_testcase extends advanced_testcase { * @coversNothing * @dataProvider provider_ml_processors * @param string $predictionsprocessorclass + * @param array $forcedconfig * @return null */ - public function test_ml_evaluation_trained_model($predictionsprocessorclass) { + public function test_ml_evaluation_trained_model($predictionsprocessorclass, $forcedconfig) { $this->resetAfterTest(true); + + $this->set_forced_config($forcedconfig); + $predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass); + $this->setAdminuser(); set_config('enabled_stores', 'logstore_standard', 'tool_log'); @@ -575,12 +604,6 @@ class core_analytics_prediction_testcase extends advanced_testcase { // Generate training data. $this->generate_courses(50); - // 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->update(true, false, '\\core\\analytics\\time_splitting\\quarters', get_class($predictionsprocessor)); $model->train(); @@ -824,6 +847,41 @@ class core_analytics_prediction_testcase extends advanced_testcase { } } + /** + * Forces some configuration values. + * + * @param array $forcedconfig + */ + protected function set_forced_config($forcedconfig) { + \core_analytics\manager::reset_prediction_processors(); + + if (empty($forcedconfig)) { + return; + } + foreach ($forcedconfig as $pluginname => $pluginconfig) { + foreach ($pluginconfig as $name => $value) { + set_config($name, $value, $pluginname); + } + } + } + + /** + * Is the provided processor ready using the current configuration in the site? + * + * @param string $predictionsprocessorclass + * @return \core_analytics\predictor + */ + protected function is_predictions_processor_ready(string $predictionsprocessorclass) { + // We repeat the test for all prediction processors. + $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false); + $ready = $predictionsprocessor->is_ready(); + if ($ready !== true) { + $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready: ' . $ready); + } + + return $predictionsprocessor; + } + /** * add_prediction_processors * @@ -834,12 +892,32 @@ class core_analytics_prediction_testcase extends advanced_testcase { $return = array(); - // We need to test all system prediction processors. + if (defined('TEST_MLBACKEND_PYTHON_HOST') && defined('TEST_MLBACKEND_PYTHON_PORT') + && defined('TEST_MLBACKEND_PYTHON_USERNAME') && defined('TEST_MLBACKEND_PYTHON_USERNAME')) { + $testpythonserver = true; + } + + // We need to test all prediction processors in the system. $predictionprocessors = \core_analytics\manager::get_all_prediction_processors(); - foreach ($predictionprocessors as $classfullname => $unused) { + foreach ($predictionprocessors as $classfullname => $predictionsprocessor) { + foreach ($cases as $key => $case) { - $newkey = $key . '-' . $classfullname; - $return[$newkey] = $case + array('predictionsprocessorclass' => $classfullname); + $return[$key . '-' . $classfullname] = $case + ['predictionsprocessor' => $classfullname, 'forcedconfig' => null]; + } + + if ($predictionsprocessor instanceof \mlbackend_python\processor && !empty($testpythonserver)) { + // We also want to test the python processor using the server. + + foreach ($cases as $key => $case) { + + // We want the configuration to be forced during the test as things like importing models create new + // instances of ML backend processors during the process. + $forcedconfig = ['mlbackend_python' => ['useserver' => true, 'host' => TEST_MLBACKEND_PYTHON_HOST, + 'port' => TEST_MLBACKEND_PYTHON_PORT, 'secure' => false, 'username' => TEST_MLBACKEND_PYTHON_USERNAME, + 'password' => TEST_MLBACKEND_PYTHON_PASSWORD]]; + $casekey = $key . '-' . $classfullname . '-server'; + $return[$casekey] = $case + ['predictionsprocessor' => $classfullname, 'forcedconfig' => $forcedconfig]; + } } } diff --git a/analytics/upgrade.txt b/analytics/upgrade.txt index adb4b9cb7d4..903e5308d58 100644 --- a/analytics/upgrade.txt +++ b/analytics/upgrade.txt @@ -15,6 +15,7 @@ information provided here is intended especially for developers. * A new \core_analytics\local\time_splitting\past_periodic abstract class has been added. Time-splitting methods extending \core_analytics\local\time_splitting\periodic directly should be extending past_periodic now. 'periodic' can still be directly extended by implementing get_next_range and get_first_start methods. +* \core_analytics\predictor::delete_output_dir has a new 2nd parameter, $uniquemodelid. === 3.7 === diff --git a/lang/en/admin.php b/lang/en/admin.php index 4838f0c427a..c31faaddea2 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -790,6 +790,7 @@ $string['minpasswordlower'] = 'Lowercase letters'; $string['minpasswordnonalphanum'] = 'Non-alphanumeric characters'; $string['minpasswordupper'] = 'Uppercase letters'; $string['misc'] = 'Miscellaneous'; +$string['mlbackendsettings'] = 'Machine learning backend settings'; $string['mnetrestore_extusers'] = 'Note: This backup file contains remote Moodle Network user accounts which will be restored as part of the process.'; $string['mnetrestore_extusers_admin'] = 'Note: This backup file seems to come from a different Moodle installation and contains remote Moodle Network user accounts. The restore process will try to match the Moodle Network hosts for all created users. Those not matching will be automatically switched to internal authentication (instead of mnet one). The restore log will inform you about that.'; $string['mnetrestore_extusers_mismatch'] = 'Note: This backup file apparently originates from a different Moodle installation and contains remote Moodle Network user accounts that may fail to restore. This operation is unsupported. If you are certain that it was created on this Moodle installation, or you can ensure that all the needed Moodle Network Hosts are configured, you may want to still try the restore.'; diff --git a/lib/classes/plugininfo/mlbackend.php b/lib/classes/plugininfo/mlbackend.php index 884b0fe1c11..be04eefd432 100644 --- a/lib/classes/plugininfo/mlbackend.php +++ b/lib/classes/plugininfo/mlbackend.php @@ -50,6 +50,39 @@ class mlbackend extends base { * @return null|string node name or null if plugin does not create settings node (default) */ public function get_settings_section_name() { - return 'mlbackendsetting' . $this->name; + return 'mlbackendsettings' . $this->name; + } + + /** + * Load the global settings for a particular availability plugin (if there are any) + * + * @param \part_of_admin_tree $adminroot + * @param string $parentnodename + * @param bool $hassiteconfig + * @return void + */ + public function load_settings(\part_of_admin_tree $adminroot, $parentnodename, $hassiteconfig) { + global $CFG, $USER, $DB, $OUTPUT, $PAGE; // In case settings.php wants to refer to them. + $ADMIN = $adminroot; // May be used in settings.php. + $plugininfo = $this; // Also can be used inside settings.php. + + if (!$this->is_installed_and_upgraded()) { + return; + } + + if (!$hassiteconfig) { + return; + } + + $section = $this->get_settings_section_name(); + + $settings = null; + if (file_exists($this->full_path('settings.php'))) { + $settings = new \admin_settingpage($section, $this->displayname, 'moodle/site:config', $this->is_enabled() === false); + include($this->full_path('settings.php')); // This may also set $settings to null. + } + if ($settings) { + $ADMIN->add($parentnodename, $settings); + } } } diff --git a/lib/mlbackend/php/classes/processor.php b/lib/mlbackend/php/classes/processor.php index 503f9a64f66..98b98cb2c2b 100644 --- a/lib/mlbackend/php/classes/processor.php +++ b/lib/mlbackend/php/classes/processor.php @@ -88,9 +88,10 @@ class processor implements \core_analytics\classifier, \core_analytics\regressor * Delete the output directory. * * @param string $modeloutputdir + * @param string $uniqueid * @return null */ - public function delete_output_dir($modeloutputdir) { + public function delete_output_dir($modeloutputdir, $uniqueid) { remove_dir($modeloutputdir); } diff --git a/lib/mlbackend/python/classes/processor.php b/lib/mlbackend/python/classes/processor.php index 364c4578e0f..634e4b70bd0 100644 --- a/lib/mlbackend/python/classes/processor.php +++ b/lib/mlbackend/python/classes/processor.php @@ -40,6 +40,12 @@ class processor implements \core_analytics\classifier, \core_analytics\regresso */ const REQUIRED_PIP_PACKAGE_VERSION = '2.1.0'; + /** + * The python package is installed in a server. + * @var bool + */ + protected $useserver; + /** * The path to the Python bin. * @@ -47,15 +53,58 @@ class processor implements \core_analytics\classifier, \core_analytics\regresso */ protected $pathtopython; + /** + * Remote server host + * @var string + */ + protected $host; + + /** + * Remote server port + * @var int + */ + protected $port; + + /** + * Whether to use http or https. + * @var bool + */ + protected $secure; + + /** + * Server username. + * @var string + */ + protected $username; + + /** + * Server password for $this->username. + * @var string + */ + protected $password; + /** * The constructor. + * */ public function __construct() { global $CFG; - // Set the python location if there is a value. - if (!empty($CFG->pathtopython)) { - $this->pathtopython = $CFG->pathtopython; + $config = get_config('mlbackend_python'); + + $this->useserver = !empty($config->useserver); + + if (!$this->useserver) { + // Set the python location if there is a value. + if (!empty($CFG->pathtopython)) { + $this->pathtopython = $CFG->pathtopython; + } + } else { + $this->host = $config->host ?? ''; + $this->port = $config->port ?? ''; + $this->secure = $config->secure ?? false; + $this->username = $config->username ?? ''; + $this->password = $config->password ?? ''; } } @@ -65,6 +114,20 @@ class processor implements \core_analytics\classifier, \core_analytics\regresso * @return bool|string Returns true on success, a string detailing the error otherwise */ public function is_ready() { + + if (!$this->useserver) { + return $this->is_webserver_ready(); + } else { + return $this->is_python_server_ready(); + } + } + + /** + * Checks if the python package is available in the web server executing this script. + * + * @return bool|string Returns true on success, a string detailing the error otherwise + */ + protected function is_webserver_ready() { if (empty($this->pathtopython)) { $settingurl = new \moodle_url('/admin/settings.php', array('section' => 'systempaths')); return get_string('pythonpathnotdefined', 'mlbackend_python', $settingurl->out()); @@ -78,52 +141,72 @@ class processor implements \core_analytics\classifier, \core_analytics\regresso // Execute it sending the standard error to $output. $result = exec($cmd . ' 2>&1', $output, $exitcode); - $vercheck = self::check_pip_package_version($result); - - if ($vercheck === 0) { - return true; - } - if ($exitcode != 0) { return get_string('pythonpackagenotinstalled', 'mlbackend_python', $cmd); } - if ($result) { - $a = [ - 'installed' => $result, - 'required' => self::REQUIRED_PIP_PACKAGE_VERSION, - ]; + $vercheck = self::check_pip_package_version($result); + return $this->version_check_return($result, $vercheck); + } - if ($vercheck < 0) { - return get_string('packageinstalledshouldbe', 'mlbackend_python', $a); + /** + * Checks if the server can be accessed. + * + * @return bool|string True or an error string. + */ + protected function is_python_server_ready() { - } else if ($vercheck > 0) { - return get_string('packageinstalledtoohigh', 'mlbackend_python', $a); - } + if (empty($this->host) || empty($this->port) || empty($this->username) || empty($this->password)) { + return get_string('errornoconfigdata', 'mlbackend_python'); } - return get_string('pythonpackagenotinstalled', 'mlbackend_python', $cmd); + // Connection is allowed to use 'localhost' and other potentially blocked hosts/ports. + $curl = new \curl(['ignoresecurity' => true]); + $responsebody = $curl->get($this->get_server_url('version')->out(false)); + if ($curl->info['http_code'] !== 200) { + return get_string('errorserver', 'mlbackend_python', $this->server_error_str($curl->info['http_code'], $responsebody)); + } + + $vercheck = self::check_pip_package_version($responsebody); + return $this->version_check_return($responsebody, $vercheck); + } /** * Delete the model version output directory. * + * @throws \moodle_exception * @param string $uniqueid * @param string $modelversionoutputdir * @return null */ public function clear_model($uniqueid, $modelversionoutputdir) { - remove_dir($modelversionoutputdir); + if (!$this->useserver) { + remove_dir($modelversionoutputdir); + } else { + // Use the server. + + $url = $this->get_server_url('deletemodel'); + list($responsebody, $httpcode) = $this->server_request($url, 'post', ['uniqueid' => $uniqueid]); + } } /** * Delete the model output directory. * + * @throws \moodle_exception * @param string $modeloutputdir + * @param string $uniqueid * @return null */ - public function delete_output_dir($modeloutputdir) { - remove_dir($modeloutputdir); + public function delete_output_dir($modeloutputdir, $uniqueid) { + if (!$this->useserver) { + remove_dir($modeloutputdir); + } else { + + $url = $this->get_server_url('deletemodel'); + list($responsebody, $httpcode) = $this->server_request($url, 'post', ['uniqueid' => $uniqueid]); + } } /** @@ -136,44 +219,28 @@ class processor implements \core_analytics\classifier, \core_analytics\regresso */ public function train_classification($uniqueid, \stored_file $dataset, $outputdir) { - // Obtain the physical route to the file. - $datasetpath = $this->get_file_path($dataset); + if (!$this->useserver) { + // Use the local file system. - $cmd = "{$this->pathtopython} -m moodlemlbackend.training " . - escapeshellarg($uniqueid) . ' ' . - escapeshellarg($outputdir) . ' ' . - escapeshellarg($datasetpath); + list($result, $exitcode) = $this->exec_command('training', [$uniqueid, $outputdir, + $this->get_file_path($dataset)], 'errornopredictresults'); - if (!PHPUNIT_TEST && CLI_SCRIPT) { - debugging($cmd, DEBUG_DEVELOPER); - } + } else { + // Use the server. - $output = null; - $exitcode = null; - $result = exec($cmd, $output, $exitcode); + $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($outputdir), + 'dataset' => $dataset]; - if (!$result) { - throw new \moodle_exception('errornopredictresults', 'analytics'); + $url = $this->get_server_url('training'); + list($result, $httpcode) = $this->server_request($url, 'post', $requestparams); } if (!$resultobj = json_decode($result)) { throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg()); } - if ($exitcode != 0) { - if (!empty($resultobj->errors)) { - $errors = $resultobj->errors; - if (is_array($errors)) { - $errors = implode(', ', $errors); - } - } else if (!empty($resultobj->info)) { - // Show info if no errors are returned. - $errors = $resultobj->info; - if (is_array($errors)) { - $errors = implode(', ', $errors); - } - } - $resultobj->info = array(get_string('errorpredictionsprocessor', 'analytics', $errors)); + if ($resultobj->status != 0) { + $resultobj = $this->format_error_info($resultobj); } return $resultobj; @@ -189,44 +256,29 @@ class processor implements \core_analytics\classifier, \core_analytics\regresso */ public function classify($uniqueid, \stored_file $dataset, $outputdir) { - // Obtain the physical route to the file. - $datasetpath = $this->get_file_path($dataset); + if (!$this->useserver) { + // Use the local file system. - $cmd = "{$this->pathtopython} -m moodlemlbackend.prediction " . - escapeshellarg($uniqueid) . ' ' . - escapeshellarg($outputdir) . ' ' . - escapeshellarg($datasetpath); + list($result, $exitcode) = $this->exec_command('prediction', [$uniqueid, $outputdir, + $this->get_file_path($dataset)], 'errornopredictresults'); - if (!PHPUNIT_TEST && CLI_SCRIPT) { - debugging($cmd, DEBUG_DEVELOPER); - } + } else { + // Use the server. - $output = null; - $exitcode = null; - $result = exec($cmd, $output, $exitcode); + $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($outputdir), + 'dataset' => $dataset]; - if (!$result) { - throw new \moodle_exception('errornopredictresults', 'analytics'); + $url = $this->get_server_url('prediction'); + list($result, $httpcode) = $this->server_request($url, 'post', $requestparams); } if (!$resultobj = json_decode($result)) { throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg()); } - if ($exitcode != 0) { - if (!empty($resultobj->errors)) { - $errors = $resultobj->errors; - if (is_array($errors)) { - $errors = implode(', ', $errors); - } - } else if (!empty($resultobj->info)) { - // Show info if no errors are returned. - $errors = $resultobj->info; - if (is_array($errors)) { - $errors = implode(', ', $errors); - } - } - $resultobj->info = array(get_string('errorpredictionsprocessor', 'analytics', $errors)); + + if ($resultobj->status != 0) { + $resultobj = $this->format_error_info($resultobj); } return $resultobj; @@ -245,37 +297,72 @@ class processor implements \core_analytics\classifier, \core_analytics\regresso */ public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir, $trainedmodeldir) { + global $CFG; - // Obtain the physical route to the file. - $datasetpath = $this->get_file_path($dataset); + if (!$this->useserver) { + // Use the local file system. - $cmd = "{$this->pathtopython} -m moodlemlbackend.evaluation " . - escapeshellarg($uniqueid) . ' ' . - escapeshellarg($outputdir) . ' ' . - escapeshellarg($datasetpath) . ' ' . - escapeshellarg(\core_analytics\model::MIN_SCORE) . ' ' . - escapeshellarg($maxdeviation) . ' ' . - escapeshellarg($niterations); + $datasetpath = $this->get_file_path($dataset); - if ($trainedmodeldir) { - $cmd .= ' ' . escapeshellarg($trainedmodeldir); + $params = [$uniqueid, $outputdir, $datasetpath, \core_analytics\model::MIN_SCORE, + $maxdeviation, $niterations]; + + if ($trainedmodeldir) { + $params[] = $trainedmodeldir; + } + + list($result, $exitcode) = $this->exec_command('evaluation', $params, 'errornopredictresults'); + if (!$resultobj = json_decode($result)) { + throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg()); + } + + } else { + // Use the server. + + $requestparams = ['uniqueid' => $uniqueid, 'minscore' => \core_analytics\model::MIN_SCORE, + 'maxdeviation' => $maxdeviation, 'niterations' => $niterations, + 'dirhash' => $this->hash_dir($outputdir), 'dataset' => $dataset]; + + if ($trainedmodeldir) { + $requestparams['trainedmodeldirhash'] = $this->hash_dir($trainedmodeldir); + } + + $url = $this->get_server_url('evaluation'); + list($result, $httpcode) = $this->server_request($url, 'post', $requestparams); + + if (!$resultobj = json_decode($result)) { + throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg()); + } + + // We need an extra request to get the resources generated during the evaluation process. + + // Directory to temporarly store the evaluation log zip returned by the server. + $evaluationtmpdir = make_request_directory('mlbackend_python_evaluationlog'); + $evaluationzippath = $evaluationtmpdir . DIRECTORY_SEPARATOR . 'evaluationlog.zip'; + + $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($outputdir), + 'runid' => $resultobj->runid]; + + $url = $this->get_server_url('evaluationlog'); + list($result, $httpcode) = $this->server_request($url, 'download_one', $requestparams, + ['filepath' => $evaluationzippath]); + + $rundir = $outputdir . DIRECTORY_SEPARATOR . 'logs' . DIRECTORY_SEPARATOR . $resultobj->runid; + if (!mkdir($rundir, $CFG->directorypermissions, true)) { + throw new \moodle_exception('errorexportmodelresult', 'analytics'); + } + + $zip = new \zip_packer(); + $success = $zip->extract_to_pathname($evaluationzippath, $rundir, null, null, true); + if (!$success) { + $a = 'The evaluation files can not be exported to ' . $rundir; + throw new \moodle_exception('errorpredictionsprocessor', 'analytics', '', $a); + } + + $resultobj->dir = $rundir; } - if (!PHPUNIT_TEST && CLI_SCRIPT) { - debugging($cmd, DEBUG_DEVELOPER); - } - - $output = null; - $exitcode = null; - $result = exec($cmd, $output, $exitcode); - - if (!$result) { - throw new \moodle_exception('errornopredictresults', 'analytics'); - } - - if (!$resultobj = json_decode($result)) { - throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg()); - } + $resultobj = $this->add_extra_result_info($resultobj); return $resultobj; } @@ -290,29 +377,36 @@ class processor implements \core_analytics\classifier, \core_analytics\regresso */ 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 (!$this->useserver) { + // Use the local file system. - if (!PHPUNIT_TEST && CLI_SCRIPT) { - debugging($cmd, DEBUG_DEVELOPER); - } + // We include an exporttmpdir as we want to be sure that the file is not deleted after the + // python process finishes. + list($exportdir, $exitcode) = $this->exec_command('export', [$uniqueid, $modeldir, $exporttmpdir], + 'errorexportmodelresult'); - $output = null; - $exitcode = null; - $exportdir = exec($cmd, $output, $exitcode); + if ($exitcode != 0) { + throw new \moodle_exception('errorexportmodelresult', 'analytics'); + } - if ($exitcode != 0) { - throw new \moodle_exception('errorexportmodelresult', 'analytics'); - } + } else { + // Use the server. - if (!$exportdir) { - throw new \moodle_exception('errorexportmodelresult', 'analytics'); + $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($modeldir)]; + + $exportzippath = $exporttmpdir . DIRECTORY_SEPARATOR . 'export.zip'; + $url = $this->get_server_url('export'); + list($result, $httpcode) = $this->server_request($url, 'download_one', $requestparams, + ['filepath' => $exportzippath]); + + $exportdir = make_request_directory(); + $zip = new \zip_packer(); + $success = $zip->extract_to_pathname($exportzippath, $exportdir, null, null, true); + if (!$success) { + throw new \moodle_exception('errorexportmodelresult', 'analytics'); + } } return $exportdir; @@ -328,28 +422,33 @@ class processor implements \core_analytics\classifier, \core_analytics\regresso */ public function import(string $uniqueid, string $modeldir, string $importdir) : bool { - $cmd = "{$this->pathtopython} -m moodlemlbackend.import " . - escapeshellarg($uniqueid) . ' ' . - escapeshellarg($modeldir) . ' ' . - escapeshellarg($importdir); + if (!$this->useserver) { + // Use the local file system. - if (!PHPUNIT_TEST && CLI_SCRIPT) { - debugging($cmd, DEBUG_DEVELOPER); + list($result, $exitcode) = $this->exec_command('import', [$uniqueid, $modeldir, $importdir], + 'errorimportmodelresult'); + + if ($exitcode != 0) { + throw new \moodle_exception('errorimportmodelresult', 'analytics'); + } + + } else { + // Use the server. + + // Zip the $importdir to send a single file. + $importzipfile = $this->zip_dir($importdir); + if (!$importzipfile) { + // There was an error zipping the directory. + throw new \moodle_exception('errorimportmodelresult', 'analytics'); + } + + $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($modeldir), + 'importzip' => curl_file_create($importzipfile, null, 'import.zip')]; + $url = $this->get_server_url('import'); + list($result, $httpcode) = $this->server_request($url, 'post', $requestparams); } - $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; + return (bool)$result; } /** @@ -441,4 +540,209 @@ class processor implements \core_analytics\classifier, \core_analytics\regresso return 0; } + + /** + * Executes the specified module. + * + * @param string $modulename + * @param array $params + * @param string $errorlangstr + * @return array [0] is the result body and [1] the exit code. + */ + protected function exec_command(string $modulename, array $params, string $errorlangstr) { + + $cmd = $this->pathtopython . ' -m moodlemlbackend.' . $modulename . ' '; + foreach ($params as $param) { + $cmd .= escapeshellarg($param) . ' '; + } + + if (!PHPUNIT_TEST && CLI_SCRIPT) { + debugging($cmd, DEBUG_DEVELOPER); + } + + $output = null; + $exitcode = null; + $result = exec($cmd, $output, $exitcode); + + if (!$result) { + throw new \moodle_exception($errorlangstr, 'analytics'); + } + + return [$result, $exitcode]; + } + + /** + * Formats the errors and info in a single info string. + * + * @param \stdClass $resultobj + * @return \stdClass + */ + private function format_error_info(\stdClass $resultobj) { + if (!empty($resultobj->errors)) { + $errors = $resultobj->errors; + if (is_array($errors)) { + $errors = implode(', ', $errors); + } + } else if (!empty($resultobj->info)) { + // Show info if no errors are returned. + $errors = $resultobj->info; + if (is_array($errors)) { + $errors = implode(', ', $errors); + } + } + $resultobj->info = array(get_string('errorpredictionsprocessor', 'analytics', $errors)); + + return $resultobj; + } + + /** + * Returns the url to the python ML server. + * + * @param string|null $path + * @return \moodle_url + */ + private function get_server_url(?string $path = null) { + $protocol = !empty($this->secure) ? 'https' : 'http'; + $url = $protocol . '://' . rtrim($this->host, '/'); + if (!empty($this->port)) { + $url .= ':' . $this->port; + } + + if ($path) { + $url .= '/' . $path; + } + + return new \moodle_url($url); + } + + /** + * Sends a request to the python ML server. + * + * @param \moodle_url $url The requested url in the python ML server + * @param string $method The curl method to use + * @param array $requestparams Curl request params + * @param array|null $options Curl request options + * @return array [0] for the response body and [1] for the http code + */ + protected function server_request($url, string $method, array $requestparams, ?array $options = null) { + + if ($method !== 'post' && $method !== 'get' && $method !== 'download_one') { + throw new \coding_exception('Incorrect request method provided. Only "get", "post" and "download_one" + actions are available.'); + } + + // Connection is allowed to use 'localhost' and other potentially blocked hosts/ports. + $curl = new \curl(['ignoresecurity' => true]); + + $authorization = $this->username . ':' . $this->password; + $curl->setHeader('Authorization: Basic ' . base64_encode($authorization)); + + $responsebody = $curl->{$method}($url, $requestparams, $options); + + if ($curl->info['http_code'] !== 200) { + throw new \moodle_exception('errorserver', 'mlbackend_python', '', + $this->server_error_str($curl->info['http_code'], $responsebody)); + } + + return [$responsebody, $curl->info['http_code']]; + } + + /** + * Adds extra information to results info. + * + * @param \stdClass $resultobj + * @return \stdClass + */ + protected function add_extra_result_info(\stdClass $resultobj): \stdClass { + + if (!empty($resultobj->dir)) { + $dir = $resultobj->dir . DIRECTORY_SEPARATOR . 'tensor'; + $resultobj->info[] = get_string('tensorboardinfo', 'mlbackend_python', $dir); + } + return $resultobj; + } + + /** + * Returns the proper return value for the version checking. + * + * @param string $actual Actual moodlemlbackend version + * @param int $vercheck Version checking result + * @return true|string Returns true on success, a string detailing the error otherwise + */ + private function version_check_return($actual, $vercheck) { + + if ($vercheck === 0) { + return true; + } + + if ($actual) { + $a = [ + 'installed' => $actual, + 'required' => self::REQUIRED_PIP_PACKAGE_VERSION, + ]; + + if ($vercheck < 0) { + return get_string('packageinstalledshouldbe', 'mlbackend_python', $a); + + } else if ($vercheck > 0) { + return get_string('packageinstalledtoohigh', 'mlbackend_python', $a); + } + } + + if (!$this->useserver) { + $cmd = "{$this->pathtopython} -m moodlemlbackend.version"; + } else { + // We can't not know which is the python bin in the python ML server, the most likely + // value is 'python'. + $cmd = "python -m moodlemlbackend.version"; + } + return get_string('pythonpackagenotinstalled', 'mlbackend_python', $cmd); + } + + /** + * Hashes the provided dir as a string. + * + * @param string $dir Directory path + * @return string Hash + */ + private function hash_dir(string $dir) { + return md5($dir); + } + + /** + * Zips the provided directory. + * + * @param string $dir Directory path + * @return string The zip filename + */ + private function zip_dir(string $dir) { + + $ziptmpdir = make_request_directory('mlbackend_python'); + $ziptmpfile = $ziptmpdir . DIRECTORY_SEPARATOR . 'mlbackend.zip'; + + $files = get_directory_list($dir); + $zipfiles = []; + foreach ($files as $file) { + $fullpath = $dir . DIRECTORY_SEPARATOR . $file; + // Use the relative path to the file as the path in the zip. + $zipfiles[$file] = $fullpath; + } + + $zip = new \zip_packer(); + if (!$zip->archive_to_pathname($zipfiles, $ziptmpfile)) { + return false; + } + + return $ziptmpfile; + } + + /** + * Error string for httpcode !== 200 + * + * @param int $httpstatuscode The HTTP status code + * @param string $responsebody The body of the response + */ + private function server_error_str(int $httpstatuscode, string $responsebody): string { + return 'HTTP status code ' . $httpstatuscode . ': ' . $responsebody; + } } diff --git a/lib/mlbackend/python/lang/en/mlbackend_python.php b/lib/mlbackend/python/lang/en/mlbackend_python.php index f5bcb727fca..5cc2050b72d 100644 --- a/lib/mlbackend/python/lang/en/mlbackend_python.php +++ b/lib/mlbackend/python/lang/en/mlbackend_python.php @@ -22,9 +22,25 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +$string['errornoconfigdata'] = 'The server configuration is not complete.'; +$string['errorserver'] = 'Server error {$a}'; +$string['host'] = 'Host'; +$string['hostdesc'] = 'Host'; $string['packageinstalledshouldbe'] = '"moodlemlbackend" python package should be updated. The required version is "{$a->required}" and the installed version is "{$a->installed}"'; $string['packageinstalledtoohigh'] = '"moodlemlbackend" python package is not compatible with this Moodle version. The required version is "{$a->required}" or higher as long as it is API-compatible. The installed version "{$a->installed}" is too high.'; $string['pluginname'] = 'Python machine learning backend'; +$string['port'] = 'Port'; +$string['portdesc'] = 'Port'; $string['privacy:metadata'] = 'The Python machine learning backend plugin does not store any personal data.'; $string['pythonpackagenotinstalled'] = '"moodlemlbackend" python package is not installed or there is a problem with it. Please execute "{$a}" from command line interface for more info'; $string['pythonpathnotdefined'] = 'The path to your executable Python binary has not been defined. Please visit "{$a}" to set it.'; +$string['serversettingsinfo'] = 'Tick "Use a server" setting to show the server settings.'; +$string['username'] = 'Username'; +$string['usernamedesc'] = 'String of characters used as a username to communicate between your Moodle server and the python server'; +$string['password'] = 'Password'; +$string['passworddesc'] = 'String of characters used as a password to communicate between your Moodle server and the python server'; +$string['secure'] = 'Use HTTPS'; +$string['securedesc'] = 'Whether to use HTTP or HTTPS'; +$string['useserver'] = 'Use a server'; +$string['useserverdesc'] = 'The machine learning backend python package is not installed in the web server but in a different server.'; +$string['tensorboardinfo'] = 'Launch TensorBoard from command line by typing tensorboard --logdir=\'{$a}\' in your web server.'; \ No newline at end of file diff --git a/lib/mlbackend/python/settings.php b/lib/mlbackend/python/settings.php new file mode 100644 index 00000000000..56667cb7d38 --- /dev/null +++ b/lib/mlbackend/python/settings.php @@ -0,0 +1,54 @@ +. + +/** + * Administration settings definitions for mlbackend_python. + * + * @package mlbackend_python + * @copyright 2019 David Monllaó + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +if ($ADMIN->fulltree) { + + $info = $OUTPUT->notification(get_string('serversettingsinfo', 'mlbackend_python'), 'info'); + $settings->add(new admin_setting_heading('mlbackend_python/serversettingsinfo', '', $info)); + + $settings->add(new admin_setting_configcheckbox('mlbackend_python/useserver', get_string('useserver', 'mlbackend_python'), + get_string('useserverdesc', 'mlbackend_python'), 0)); + + $settings->add(new admin_setting_configtext('mlbackend_python/host', get_string('host', 'mlbackend_python'), + get_string('host', 'mlbackend_python'), '', PARAM_HOST)); + $settings->hide_if('mlbackend_python/host', 'mlbackend_python/useserver', 'neq', '1'); + + $settings->add(new admin_setting_configtext('mlbackend_python/port', get_string('port', 'mlbackend_python'), + get_string('port', 'mlbackend_python'), '', PARAM_INT)); + $settings->hide_if('mlbackend_python/port', 'mlbackend_python/useserver', 'neq', '1'); + + $settings->add(new admin_setting_configcheckbox('mlbackend_python/secure', get_string('secure', 'mlbackend_python'), + get_string('securedesc', 'mlbackend_python'), 0)); + $settings->hide_if('mlbackend_python/secure', 'mlbackend_python/useserver', 'neq', '1'); + + $settings->add(new admin_setting_configtext('mlbackend_python/username', get_string('username', 'mlbackend_python'), + get_string('usernamedesc', 'mlbackend_python'), 'default', PARAM_ALPHANUMEXT)); + $settings->hide_if('mlbackend_python/username', 'mlbackend_python/useserver', 'neq', '1'); + + $settings->add(new admin_setting_configtext('mlbackend_python/password', get_string('password', 'mlbackend_python'), + get_string('passworddesc', 'mlbackend_python'), '', PARAM_ALPHANUMEXT)); + $settings->hide_if('mlbackend_python/password', 'mlbackend_python/useserver', 'neq', '1'); +} \ No newline at end of file diff --git a/lib/mlbackend/python/version.php b/lib/mlbackend/python/version.php index 6edab7a4be1..15a3019b340 100644 --- a/lib/mlbackend/python/version.php +++ b/lib/mlbackend/python/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2019052000; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2019052001; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2019051100; // Requires this Moodle version. $plugin->component = 'mlbackend_python'; // Full name of the plugin (used for diagnostics).