diff --git a/analytics/classes/manager.php b/analytics/classes/manager.php index 8fcf6e58b1f..da9d26e20d2 100644 --- a/analytics/classes/manager.php +++ b/analytics/classes/manager.php @@ -40,6 +40,11 @@ class manager { */ const DEFAULT_MLBACKEND = '\mlbackend_php\processor'; + /** + * Name of the file where components declare their models. + */ + const ANALYTICS_FILENAME = 'db/analytics.php'; + /** * @var \core_analytics\predictor[] */ @@ -107,7 +112,9 @@ class manager { $params['trained'] = 1; } if ($predictioncontext) { - $conditions[] = "EXISTS (SELECT 'x' FROM {analytics_predictions} ap WHERE ap.modelid = am.id AND ap.contextid = :contextid)"; + $conditions[] = "EXISTS (SELECT 'x' + FROM {analytics_predictions} ap + WHERE ap.modelid = am.id AND ap.contextid = :contextid)"; $params['contextid'] = $predictioncontext->id; } $sql .= ' WHERE ' . implode(' AND ', $conditions); @@ -502,84 +509,16 @@ class manager { } /** - * Adds the models included with moodle core to the system. + * Used to be used to add models included with the Moodle core. * + * @deprecated Deprecated since Moodle 3.7 (MDL-61667) - Use lib/db/analytics.php instead. + * @todo Remove this method in Moodle 4.1 (MDL-65186). * @return void */ public static function add_builtin_models() { - $target = self::get_target('\core\analytics\target\course_dropout'); - - // Community of inquiry indicators. - $coiindicators = array( - '\mod_assign\analytics\indicator\cognitive_depth', - '\mod_assign\analytics\indicator\social_breadth', - '\mod_book\analytics\indicator\cognitive_depth', - '\mod_book\analytics\indicator\social_breadth', - '\mod_chat\analytics\indicator\cognitive_depth', - '\mod_chat\analytics\indicator\social_breadth', - '\mod_choice\analytics\indicator\cognitive_depth', - '\mod_choice\analytics\indicator\social_breadth', - '\mod_data\analytics\indicator\cognitive_depth', - '\mod_data\analytics\indicator\social_breadth', - '\mod_feedback\analytics\indicator\cognitive_depth', - '\mod_feedback\analytics\indicator\social_breadth', - '\mod_folder\analytics\indicator\cognitive_depth', - '\mod_folder\analytics\indicator\social_breadth', - '\mod_forum\analytics\indicator\cognitive_depth', - '\mod_forum\analytics\indicator\social_breadth', - '\mod_glossary\analytics\indicator\cognitive_depth', - '\mod_glossary\analytics\indicator\social_breadth', - '\mod_imscp\analytics\indicator\cognitive_depth', - '\mod_imscp\analytics\indicator\social_breadth', - '\mod_label\analytics\indicator\cognitive_depth', - '\mod_label\analytics\indicator\social_breadth', - '\mod_lesson\analytics\indicator\cognitive_depth', - '\mod_lesson\analytics\indicator\social_breadth', - '\mod_lti\analytics\indicator\cognitive_depth', - '\mod_lti\analytics\indicator\social_breadth', - '\mod_page\analytics\indicator\cognitive_depth', - '\mod_page\analytics\indicator\social_breadth', - '\mod_quiz\analytics\indicator\cognitive_depth', - '\mod_quiz\analytics\indicator\social_breadth', - '\mod_resource\analytics\indicator\cognitive_depth', - '\mod_resource\analytics\indicator\social_breadth', - '\mod_scorm\analytics\indicator\cognitive_depth', - '\mod_scorm\analytics\indicator\social_breadth', - '\mod_survey\analytics\indicator\cognitive_depth', - '\mod_survey\analytics\indicator\social_breadth', - '\mod_url\analytics\indicator\cognitive_depth', - '\mod_url\analytics\indicator\social_breadth', - '\mod_wiki\analytics\indicator\cognitive_depth', - '\mod_wiki\analytics\indicator\social_breadth', - '\mod_workshop\analytics\indicator\cognitive_depth', - '\mod_workshop\analytics\indicator\social_breadth', - '\core_course\analytics\indicator\completion_enabled', - '\core_course\analytics\indicator\potential_cognitive_depth', - '\core_course\analytics\indicator\potential_social_breadth', - '\core\analytics\indicator\any_access_after_end', - '\core\analytics\indicator\any_access_before_start', - '\core\analytics\indicator\any_write_action_in_course', - '\core\analytics\indicator\read_actions', - ); - $indicators = array(); - foreach ($coiindicators as $coiindicator) { - $indicator = self::get_indicator($coiindicator); - $indicators[$indicator->get_id()] = $indicator; - } - if (!\core_analytics\model::exists($target, $indicators)) { - $model = \core_analytics\model::create($target, $indicators); - } - - // No teaching model. - $target = self::get_target('\core\analytics\target\no_teaching'); - $timesplittingmethod = '\core\analytics\time_splitting\single_range'; - $noteacher = self::get_indicator('\core_course\analytics\indicator\no_teacher'); - $nostudent = self::get_indicator('\core_course\analytics\indicator\no_student'); - $indicators = array($noteacher->get_id() => $noteacher, $nostudent->get_id() => $nostudent); - if (!\core_analytics\model::exists($target, $indicators)) { - \core_analytics\model::create($target, $indicators, $timesplittingmethod); - } + debugging('core_analytics\manager::add_builtin_models() has been deprecated. Core models are now automatically '. + 'updated according to their declaration in the lib/db/analytics.php file.', DEBUG_DEVELOPER); } /** @@ -669,4 +608,155 @@ class manager { return $classes; } + + /** + * Check that all the models declared by the component are up to date. + * + * This is intended to be called during the installation / upgrade to automatically create missing models. + * + * @param string $componentname The name of the component to load models for. + * @return array \core_analytics\model[] List of actually created models. + */ + public static function update_default_models_for_component(string $componentname): array { + + $result = []; + + foreach (static::load_default_models_for_component($componentname) as $definition) { + if (!\core_analytics\model::exists(static::get_target($definition['target']))) { + $result[] = static::create_declared_model($definition); + } + } + + return $result; + } + + /** + * Return the list of models declared by the given component. + * + * @param string $componentname The name of the component to load models for. + * @throws \coding_exception Exception thrown in case of invalid syntax. + * @return array The $models description array. + */ + public static function load_default_models_for_component(string $componentname): array { + + $dir = \core_component::get_component_directory($componentname); + + if (!$dir) { + // This is either an invalid component, or a core subsystem without its own root directory. + return []; + } + + $file = $dir . '/' . self::ANALYTICS_FILENAME; + + if (!is_readable($file)) { + return []; + } + + $models = null; + include($file); + + if (!isset($models) || !is_array($models) || empty($models)) { + return []; + } + + foreach ($models as &$model) { + if (!isset($model['enabled'])) { + $model['enabled'] = false; + } else { + $model['enabled'] = clean_param($model['enabled'], PARAM_BOOL); + } + } + + static::validate_models_declaration($models); + + return $models; + } + + /** + * Validate the declaration of prediction models according the syntax expected in the component's db folder. + * + * The expected structure looks like this: + * + * [ + * [ + * 'target' => '\fully\qualified\name\of\the\target\class', + * 'indicators' => [ + * '\fully\qualified\name\of\the\first\indicator', + * '\fully\qualified\name\of\the\second\indicator', + * ], + * 'timesplitting' => '\optional\name\of\the\time_splitting\class', + * 'enabled' => true, + * ], + * ]; + * + * @param array $models List of declared models. + * @throws \coding_exception Exception thrown in case of invalid syntax. + */ + public static function validate_models_declaration(array $models) { + + foreach ($models as $model) { + if (!isset($model['target'])) { + throw new \coding_exception('Missing target declaration'); + } + + if (!static::is_valid($model['target'], '\core_analytics\local\target\base')) { + throw new \coding_exception('Invalid target classname', $model['target']); + } + + if (empty($model['indicators']) || !is_array($model['indicators'])) { + throw new \coding_exception('Missing indicators declaration'); + } + + foreach ($model['indicators'] as $indicator) { + if (!static::is_valid($indicator, '\core_analytics\local\indicator\base')) { + throw new \coding_exception('Invalid indicator classname', $indicator); + } + } + + if (isset($model['timesplitting'])) { + if (substr($model['timesplitting'], 0, 1) !== '\\') { + throw new \coding_exception('Expecting fully qualified time splitting classname', $model['timesplitting']); + } + if (!static::is_valid($model['timesplitting'], '\core_analytics\local\time_splitting\base')) { + throw new \coding_exception('Invalid time splitting classname', $model['timesplitting']); + } + } + + if (!empty($model['enabled']) && !isset($model['timesplitting'])) { + throw new \coding_exception('Cannot enable a model without time splitting method specified'); + } + } + } + + /** + * Create the defined model. + * + * @param array $definition See {@link self::validate_models_declaration()} for the syntax. + * @return \core_analytics\model + */ + public static function create_declared_model(array $definition): \core_analytics\model { + + $target = static::get_target($definition['target']); + + $indicators = []; + + foreach ($definition['indicators'] as $indicatorname) { + $indicator = static::get_indicator($indicatorname); + $indicators[$indicator->get_id()] = $indicator; + } + + if (isset($definition['timesplitting'])) { + $timesplitting = $definition['timesplitting']; + } else { + $timesplitting = false; + } + + $created = \core_analytics\model::create($target, $indicators, $timesplitting); + + if (!empty($definition['enabled'])) { + $created->enable(); + } + + return $created; + } } diff --git a/analytics/classes/model.php b/analytics/classes/model.php index 6230b758698..c0da4c082b5 100644 --- a/analytics/classes/model.php +++ b/analytics/classes/model.php @@ -346,8 +346,6 @@ class model { $timesplittingid = false, $processor = null) { global $USER, $DB; - \core_analytics\manager::check_can_manage_models(); - $indicatorclasses = self::indicator_classes($indicators); $now = time(); @@ -360,6 +358,20 @@ class model { $modelobj->timemodified = $now; $modelobj->usermodified = $USER->id; + if ($target->based_on_assumptions()) { + $modelobj->trained = 1; + } + + if ($timesplittingid) { + if (!\core_analytics\manager::is_valid($timesplittingid, '\core_analytics\local\time_splitting\base')) { + throw new \moodle_exception('errorinvalidtimesplitting', 'analytics'); + } + if (substr($timesplittingid, 0, 1) !== '\\') { + throw new \moodle_exception('errorinvalidtimesplitting', 'analytics'); + } + $modelobj->timesplitting = $timesplittingid; + } + if ($processor && !manager::is_valid($processor, '\core_analytics\classifier') && !manager::is_valid($processor, '\core_analytics\regressor')) { @@ -375,14 +387,6 @@ class model { $model = new static($modelobj); - if ($timesplittingid) { - $model->enable($timesplittingid); - } - - if ($model->is_static()) { - $model->mark_as_trained(); - } - return $model; } @@ -401,6 +405,10 @@ class model { $existingmodels = $DB->get_records('analytics_models', array('target' => $target->get_id())); + if (!$existingmodels) { + return false; + } + if (!$indicators && $existingmodels) { return true; } @@ -1049,8 +1057,6 @@ class model { public function enable($timesplittingid = false) { global $DB, $USER; - \core_analytics\manager::check_can_manage_models(); - $now = time(); if ($timesplittingid && $timesplittingid !== $this->model->timesplitting) { diff --git a/analytics/tests/fixtures/db_analytics_php/invalid_enabled.php b/analytics/tests/fixtures/db_analytics_php/invalid_enabled.php new file mode 100644 index 00000000000..c19165cf10d --- /dev/null +++ b/analytics/tests/fixtures/db_analytics_php/invalid_enabled.php @@ -0,0 +1,37 @@ +. + +/** + * Example of an invalid db/analytics.php file content used for unit tests. + * + * @package core_analytics + * @category test + * @copyright 2019 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$models = [ + [ + 'target' => 'test_target_course_level_shortname', + 'indicators' => [ + '\core_course\analytics\indicator\no_teacher', + ], + // Cannot be enabled without timesplitting defined. + 'enabled' => true, + ], +]; diff --git a/analytics/tests/fixtures/db_analytics_php/invalid_indicators.php b/analytics/tests/fixtures/db_analytics_php/invalid_indicators.php new file mode 100644 index 00000000000..0aa0a39ea31 --- /dev/null +++ b/analytics/tests/fixtures/db_analytics_php/invalid_indicators.php @@ -0,0 +1,36 @@ +. + +/** + * Example of an invalid db/analytics.php file content used for unit tests. + * + * @package core_analytics + * @category test + * @copyright 2019 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$models = [ + [ + 'target' => 'test_target_course_level_shortname', + 'indicators' => [ + '\core_course\analytics\indicator\no_teacher', + '\non\existing\class\name', + ], + ], +]; diff --git a/analytics/tests/fixtures/db_analytics_php/invalid_target.php b/analytics/tests/fixtures/db_analytics_php/invalid_target.php new file mode 100644 index 00000000000..0ea91f33e27 --- /dev/null +++ b/analytics/tests/fixtures/db_analytics_php/invalid_target.php @@ -0,0 +1,36 @@ +. + +/** + * Example of an invalid db/analytics.php file content used for unit tests. + * + * @package core_analytics + * @category test + * @copyright 2019 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$models = [ + [ + 'target' => 'there_should_be_a_valid_fully_qualified_classname_here', + 'indicators' => [ + '\core_course\analytics\indicator\no_teacher', + '\core_course\analytics\indicator\no_student', + ], + ], +]; diff --git a/analytics/tests/fixtures/db_analytics_php/invalid_time_splitting.php b/analytics/tests/fixtures/db_analytics_php/invalid_time_splitting.php new file mode 100644 index 00000000000..c0567a2c1c2 --- /dev/null +++ b/analytics/tests/fixtures/db_analytics_php/invalid_time_splitting.php @@ -0,0 +1,36 @@ +. + +/** + * Example of an invalid db/analytics.php file content used for unit tests. + * + * @package core_analytics + * @category test + * @copyright 2019 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$models = [ + [ + 'target' => 'test_target_course_level_shortname', + 'indicators' => [ + '\core_course\analytics\indicator\no_teacher', + ], + 'timesplitting' => '\non\existing\class\name', + ], +]; diff --git a/analytics/tests/fixtures/db_analytics_php/invalid_time_splitting_fq.php b/analytics/tests/fixtures/db_analytics_php/invalid_time_splitting_fq.php new file mode 100644 index 00000000000..32157f988ad --- /dev/null +++ b/analytics/tests/fixtures/db_analytics_php/invalid_time_splitting_fq.php @@ -0,0 +1,36 @@ +. + +/** + * Example of an invalid db/analytics.php file content used for unit tests. + * + * @package core_analytics + * @category test + * @copyright 2019 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$models = [ + [ + 'target' => 'test_target_course_level_shortname', + 'indicators' => [ + '\core_course\analytics\indicator\no_teacher', + ], + 'timesplitting' => 'local_customplugin_non_fully_qualified_class_name', + ], +]; diff --git a/analytics/tests/fixtures/db_analytics_php/missing_indicators.php b/analytics/tests/fixtures/db_analytics_php/missing_indicators.php new file mode 100644 index 00000000000..80e86027448 --- /dev/null +++ b/analytics/tests/fixtures/db_analytics_php/missing_indicators.php @@ -0,0 +1,32 @@ +. + +/** + * Example of an invalid db/analytics.php file content used for unit tests. + * + * @package core_analytics + * @category test + * @copyright 2019 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$models = [ + [ + 'target' => 'test_target_course_level_shortname', + ], +]; diff --git a/analytics/tests/fixtures/db_analytics_php/missing_target.php b/analytics/tests/fixtures/db_analytics_php/missing_target.php new file mode 100644 index 00000000000..3a58e575db5 --- /dev/null +++ b/analytics/tests/fixtures/db_analytics_php/missing_target.php @@ -0,0 +1,35 @@ +. + +/** + * Example of an invalid db/analytics.php file content used for unit tests. + * + * @package core_analytics + * @category test + * @copyright 2019 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$models = [ + [ + 'indicators' => [ + '\core_course\analytics\indicator\no_teacher', + '\core_course\analytics\indicator\no_student', + ], + ], +]; diff --git a/analytics/tests/fixtures/db_analytics_php/no_teaching.php b/analytics/tests/fixtures/db_analytics_php/no_teaching.php new file mode 100644 index 00000000000..ac43c145ef8 --- /dev/null +++ b/analytics/tests/fixtures/db_analytics_php/no_teaching.php @@ -0,0 +1,38 @@ +. + +/** + * Example of a valid db/analytics.php file content used for unit tests. + * + * @package core_analytics + * @category test + * @copyright 2019 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$models = [ + [ + 'target' => '\core\analytics\target\no_teaching', + 'indicators' => [ + '\core_course\analytics\indicator\no_teacher', + '\core_course\analytics\indicator\no_student', + ], + 'timesplitting' => '\core\analytics\time_splitting\single_range', + 'enabled' => true, + ], +]; diff --git a/analytics/tests/manager_test.php b/analytics/tests/manager_test.php index f4c2a1c06bf..e685bed20bb 100644 --- a/analytics/tests/manager_test.php +++ b/analytics/tests/manager_test.php @@ -150,4 +150,225 @@ class analytics_manager_testcase extends advanced_testcase { get_log_manager(true); } + /** + * Tests for the {@link \core_analytics\manager::load_default_models_for_component()} implementation. + */ + public function test_load_default_models_for_component() { + $this->resetAfterTest(); + + // Attempting to load builtin models should always work without throwing exception. + \core_analytics\manager::load_default_models_for_component('core'); + + // Attempting to load from a core subsystem without its own subsystem directory. + $this->assertSame([], \core_analytics\manager::load_default_models_for_component('core_access')); + + // Attempting to load from a non-existing subsystem. + $this->assertSame([], \core_analytics\manager::load_default_models_for_component('core_nonexistingsubsystem')); + + // Attempting to load from a non-existing plugin of a known plugin type. + $this->assertSame([], \core_analytics\manager::load_default_models_for_component('mod_foobarbazquaz12240996776')); + + // Attempting to load from a non-existing plugin type. + $this->assertSame([], \core_analytics\manager::load_default_models_for_component('foo_bar2776327736558')); + } + + /** + * Tests for the successful execution of the {@link \core_analytics\manager::validate_models_declaration()}. + */ + public function test_validate_models_declaration() { + $this->resetAfterTest(); + + // This is expected to run without an exception. + $models = $this->load_models_from_fixture_file('no_teaching'); + \core_analytics\manager::validate_models_declaration($models); + } + + /** + * Tests for the exceptions thrown by {@link \core_analytics\manager::validate_models_declaration()}. + * + * @dataProvider validate_models_declaration_exceptions_provider + * @param array $models Models declaration. + * @param string $exception Expected coding exception message. + */ + public function test_validate_models_declaration_exceptions(array $models, string $exception) { + $this->resetAfterTest(); + + $this->expectException(\coding_exception::class); + $this->expectExceptionMessage($exception); + \core_analytics\manager::validate_models_declaration($models); + } + + /** + * Data provider for the {@link self::test_validate_models_declaration_exceptions()}. + * + * @return array of (string)testcase => [(array)models, (string)expected exception message] + */ + public function validate_models_declaration_exceptions_provider() { + return [ + 'missing_target' => [ + $this->load_models_from_fixture_file('missing_target'), + 'Missing target declaration', + ], + 'invalid_target' => [ + $this->load_models_from_fixture_file('invalid_target'), + 'Invalid target classname', + ], + 'missing_indicators' => [ + $this->load_models_from_fixture_file('missing_indicators'), + 'Missing indicators declaration', + ], + 'invalid_indicators' => [ + $this->load_models_from_fixture_file('invalid_indicators'), + 'Invalid indicator classname', + ], + 'invalid_time_splitting' => [ + $this->load_models_from_fixture_file('invalid_time_splitting'), + 'Invalid time splitting classname', + ], + 'invalid_time_splitting_fq' => [ + $this->load_models_from_fixture_file('invalid_time_splitting_fq'), + 'Expecting fully qualified time splitting classname', + ], + 'invalid_enabled' => [ + $this->load_models_from_fixture_file('invalid_enabled'), + 'Cannot enable a model without time splitting method specified', + ], + ]; + } + + /** + * Loads models as declared in the given fixture file. + * + * @param string $filename + * @return array + */ + protected function load_models_from_fixture_file(string $filename) { + global $CFG; + + $models = null; + + require($CFG->dirroot.'/analytics/tests/fixtures/db_analytics_php/'.$filename.'.php'); + + return $models; + } + + /** + * Test the implementation of the {@link \core_analytics\manager::create_declared_model()}. + */ + public function test_create_declared_model() { + global $DB; + + $this->resetAfterTest(); + $this->setAdminuser(); + + $declaration = [ + 'target' => 'test_target_course_level_shortname', + 'indicators' => [ + 'test_indicator_max', + 'test_indicator_min', + 'test_indicator_fullname', + ], + ]; + + $declarationwithtimesplitting = array_merge($declaration, [ + 'timesplitting' => '\core\analytics\time_splitting\no_splitting', + ]); + + $declarationwithtimesplittingenabled = array_merge($declarationwithtimesplitting, [ + 'enabled' => true, + ]); + + // Check that no such model exists yet. + $target = \core_analytics\manager::get_target('test_target_course_level_shortname'); + $this->assertEquals(0, $DB->count_records('analytics_models', ['target' => $target->get_id()])); + $this->assertFalse(\core_analytics\model::exists($target)); + + // Check that the model is created. + $created = \core_analytics\manager::create_declared_model($declaration); + $this->assertTrue($created instanceof \core_analytics\model); + $this->assertTrue(\core_analytics\model::exists($target)); + $this->assertEquals(1, $DB->count_records('analytics_models', ['target' => $target->get_id()])); + $modelid = $created->get_id(); + + // Check that created models are disabled by default. + $existing = new \core_analytics\model($modelid); + $this->assertEquals(0, $existing->get_model_obj()->enabled); + $this->assertEquals(0, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST)); + + // Let the admin enable the model. + $existing->enable('\core\analytics\time_splitting\no_splitting'); + $this->assertEquals(1, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST)); + + // Check that further calls create a new model. + $repeated = \core_analytics\manager::create_declared_model($declaration); + $this->assertTrue($repeated instanceof \core_analytics\model); + $this->assertEquals(2, $DB->count_records('analytics_models', ['target' => $target->get_id()])); + + // Delete the models. + $existing->delete(); + $repeated->delete(); + $this->assertEquals(0, $DB->count_records('analytics_models', ['target' => $target->get_id()])); + $this->assertFalse(\core_analytics\model::exists($target)); + + // Create it again, this time with time splitting method specified. + $created = \core_analytics\manager::create_declared_model($declarationwithtimesplitting); + $this->assertTrue($created instanceof \core_analytics\model); + $this->assertTrue(\core_analytics\model::exists($target)); + $this->assertEquals(1, $DB->count_records('analytics_models', ['target' => $target->get_id()])); + $modelid = $created->get_id(); + + // Even if the time splitting method was specified, the model is still not enabled automatically. + $existing = new \core_analytics\model($modelid); + $this->assertEquals(0, $existing->get_model_obj()->enabled); + $this->assertEquals(0, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST)); + $existing->delete(); + + // Let's define the model so that it is enabled by default. + $enabled = \core_analytics\manager::create_declared_model($declarationwithtimesplittingenabled); + $this->assertTrue($enabled instanceof \core_analytics\model); + $this->assertTrue(\core_analytics\model::exists($target)); + $this->assertEquals(1, $DB->count_records('analytics_models', ['target' => $target->get_id()])); + $modelid = $enabled->get_id(); + $existing = new \core_analytics\model($modelid); + $this->assertEquals(1, $existing->get_model_obj()->enabled); + $this->assertEquals(1, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST)); + + // Let the admin disable the model. + $existing->update(0, false, false); + $this->assertEquals(0, $DB->get_field('analytics_models', 'enabled', ['target' => $target->get_id()], MUST_EXIST)); + } + + /** + * Test the implementation of the {@link \core_analytics\manager::update_default_models_for_component()}. + */ + public function test_update_default_models_for_component() { + + $this->resetAfterTest(); + $this->setAdminuser(); + + $noteaching = \core_analytics\manager::get_target('\core\analytics\target\no_teaching'); + $dropout = \core_analytics\manager::get_target('\core\analytics\target\course_dropout'); + + $this->assertTrue(\core_analytics\model::exists($noteaching)); + $this->assertTrue(\core_analytics\model::exists($dropout)); + + foreach (\core_analytics\manager::get_all_models() as $model) { + $model->delete(); + } + + $this->assertFalse(\core_analytics\model::exists($noteaching)); + $this->assertFalse(\core_analytics\model::exists($dropout)); + + $updated = \core_analytics\manager::update_default_models_for_component('moodle'); + + $this->assertEquals(2, count($updated)); + $this->assertTrue(array_pop($updated) instanceof \core_analytics\model); + $this->assertTrue(array_pop($updated) instanceof \core_analytics\model); + $this->assertTrue(\core_analytics\model::exists($noteaching)); + $this->assertTrue(\core_analytics\model::exists($dropout)); + + $repeated = \core_analytics\manager::update_default_models_for_component('moodle'); + + $this->assertSame([], $repeated); + } } diff --git a/analytics/tests/model_test.php b/analytics/tests/model_test.php index 2fc0fa97c06..0e3385ad8ff 100644 --- a/analytics/tests/model_test.php +++ b/analytics/tests/model_test.php @@ -272,16 +272,14 @@ class analytics_model_testcase extends advanced_testcase { public function test_exists() { $this->resetAfterTest(true); - global $DB; - - $count = $DB->count_records('analytics_models'); - - // No new models added if the builtin ones already exist. - \core_analytics\manager::add_builtin_models(); - $this->assertCount($count, $DB->get_records('analytics_models')); - $target = \core_analytics\manager::get_target('\core\analytics\target\no_teaching'); $this->assertTrue(\core_analytics\model::exists($target)); + + foreach (\core_analytics\manager::get_all_models() as $model) { + $model->delete(); + } + + $this->assertFalse(\core_analytics\model::exists($target)); } /** diff --git a/analytics/tests/privacy_test.php b/analytics/tests/privacy_test.php index 4e7e2ae43be..eb3a9fe70ea 100644 --- a/analytics/tests/privacy_test.php +++ b/analytics/tests/privacy_test.php @@ -86,8 +86,10 @@ class core_analytics_privacy_model_testcase extends \core_privacy\tests\provider $this->setAdminUser(); + $this->model1->enable(); $this->model1->train(); $this->model1->predict(); + $this->model2->enable(); $this->model2->train(); $this->model2->predict(); diff --git a/analytics/upgrade.txt b/analytics/upgrade.txt index 31873651154..d2036f20067 100644 --- a/analytics/upgrade.txt +++ b/analytics/upgrade.txt @@ -6,6 +6,13 @@ information provided here is intended especially for developers. * \core_analytics\regressor::evaluate_regression and \core_analytics\classifier::evaluate_classification have been updated to include a new $trainedmodeldir param. This new param will be used to evaluate the existing trained model. +* Plugins and core subsystems can now declare default prediction models by describing them in + their db/analytics.php file. Models should not be created manually via the db/install.php + file any more. +* The method \core_analytics\manager::add_builtin_models() has been deprecated. The functionality + has been replaced with automatic update of models provided by the core moodle component. There + is no need to call this method explicitly any more. Instead, adding new models can be achieved + by updating the lib/db/analytics.php file and bumping the core version. === 3.5 === diff --git a/lib/db/analytics.php b/lib/db/analytics.php new file mode 100644 index 00000000000..821ad953343 --- /dev/null +++ b/lib/db/analytics.php @@ -0,0 +1,92 @@ +. + +/** + * Defines the built-in prediction models provided by the Moodle core. + * + * @package core + * @category analytics + * @copyright 2019 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$models = [ + [ + 'target' => '\core\analytics\target\course_dropout', + 'indicators' => [ + '\core\analytics\indicator\any_access_after_end', + '\core\analytics\indicator\any_access_before_start', + '\core\analytics\indicator\any_write_action_in_course', + '\core\analytics\indicator\read_actions', + '\core_course\analytics\indicator\completion_enabled', + '\core_course\analytics\indicator\potential_cognitive_depth', + '\core_course\analytics\indicator\potential_social_breadth', + '\mod_assign\analytics\indicator\cognitive_depth', + '\mod_assign\analytics\indicator\social_breadth', + '\mod_book\analytics\indicator\cognitive_depth', + '\mod_book\analytics\indicator\social_breadth', + '\mod_chat\analytics\indicator\cognitive_depth', + '\mod_chat\analytics\indicator\social_breadth', + '\mod_choice\analytics\indicator\cognitive_depth', + '\mod_choice\analytics\indicator\social_breadth', + '\mod_data\analytics\indicator\cognitive_depth', + '\mod_data\analytics\indicator\social_breadth', + '\mod_feedback\analytics\indicator\cognitive_depth', + '\mod_feedback\analytics\indicator\social_breadth', + '\mod_folder\analytics\indicator\cognitive_depth', + '\mod_folder\analytics\indicator\social_breadth', + '\mod_forum\analytics\indicator\cognitive_depth', + '\mod_forum\analytics\indicator\social_breadth', + '\mod_glossary\analytics\indicator\cognitive_depth', + '\mod_glossary\analytics\indicator\social_breadth', + '\mod_imscp\analytics\indicator\cognitive_depth', + '\mod_imscp\analytics\indicator\social_breadth', + '\mod_label\analytics\indicator\cognitive_depth', + '\mod_label\analytics\indicator\social_breadth', + '\mod_lesson\analytics\indicator\cognitive_depth', + '\mod_lesson\analytics\indicator\social_breadth', + '\mod_lti\analytics\indicator\cognitive_depth', + '\mod_lti\analytics\indicator\social_breadth', + '\mod_page\analytics\indicator\cognitive_depth', + '\mod_page\analytics\indicator\social_breadth', + '\mod_quiz\analytics\indicator\cognitive_depth', + '\mod_quiz\analytics\indicator\social_breadth', + '\mod_resource\analytics\indicator\cognitive_depth', + '\mod_resource\analytics\indicator\social_breadth', + '\mod_scorm\analytics\indicator\cognitive_depth', + '\mod_scorm\analytics\indicator\social_breadth', + '\mod_survey\analytics\indicator\cognitive_depth', + '\mod_survey\analytics\indicator\social_breadth', + '\mod_url\analytics\indicator\cognitive_depth', + '\mod_url\analytics\indicator\social_breadth', + '\mod_wiki\analytics\indicator\cognitive_depth', + '\mod_wiki\analytics\indicator\social_breadth', + '\mod_workshop\analytics\indicator\cognitive_depth', + '\mod_workshop\analytics\indicator\social_breadth', + ], + ], + [ + 'target' => '\core\analytics\target\no_teaching', + 'indicators' => [ + '\core_course\analytics\indicator\no_teacher', + '\core_course\analytics\indicator\no_student', + ], + 'timesplitting' => '\core\analytics\time_splitting\single_range', + 'enabled' => true, + ], +]; diff --git a/lib/db/install.php b/lib/db/install.php index ddf0aa26b9d..8719b1337d2 100644 --- a/lib/db/install.php +++ b/lib/db/install.php @@ -319,7 +319,4 @@ function xmldb_main_install() { require_once($CFG->libdir . '/db/upgradelib.php'); make_default_scale(); make_competence_scale(); - - // Add built-in prediction models. - \core_analytics\manager::add_builtin_models(); } diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index d5300164973..7e5f825e2bb 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -774,80 +774,6 @@ function xmldb_main_upgrade($oldversion) { $dbman->create_table($table); } - $now = time(); - $admin = get_admin(); - - $targetname = '\core\analytics\target\course_dropout'; - if (!$DB->record_exists('analytics_models', array('target' => $targetname))) { - // We can not use API calls to create the built-in models. - $modelobj = new stdClass(); - $modelobj->target = $targetname; - $modelobj->indicators = json_encode(array( - '\mod_assign\analytics\indicator\cognitive_depth', - '\mod_assign\analytics\indicator\social_breadth', - '\mod_book\analytics\indicator\cognitive_depth', - '\mod_book\analytics\indicator\social_breadth', - '\mod_chat\analytics\indicator\cognitive_depth', - '\mod_chat\analytics\indicator\social_breadth', - '\mod_choice\analytics\indicator\cognitive_depth', - '\mod_choice\analytics\indicator\social_breadth', - '\mod_data\analytics\indicator\cognitive_depth', - '\mod_data\analytics\indicator\social_breadth', - '\mod_feedback\analytics\indicator\cognitive_depth', - '\mod_feedback\analytics\indicator\social_breadth', - '\mod_folder\analytics\indicator\cognitive_depth', - '\mod_folder\analytics\indicator\social_breadth', - '\mod_forum\analytics\indicator\cognitive_depth', - '\mod_forum\analytics\indicator\social_breadth', - '\mod_glossary\analytics\indicator\cognitive_depth', - '\mod_glossary\analytics\indicator\social_breadth', - '\mod_imscp\analytics\indicator\cognitive_depth', - '\mod_imscp\analytics\indicator\social_breadth', - '\mod_label\analytics\indicator\cognitive_depth', - '\mod_label\analytics\indicator\social_breadth', - '\mod_lesson\analytics\indicator\cognitive_depth', - '\mod_lesson\analytics\indicator\social_breadth', - '\mod_lti\analytics\indicator\cognitive_depth', - '\mod_lti\analytics\indicator\social_breadth', - '\mod_page\analytics\indicator\cognitive_depth', - '\mod_page\analytics\indicator\social_breadth', - '\mod_quiz\analytics\indicator\cognitive_depth', - '\mod_quiz\analytics\indicator\social_breadth', - '\mod_resource\analytics\indicator\cognitive_depth', - '\mod_resource\analytics\indicator\social_breadth', - '\mod_scorm\analytics\indicator\cognitive_depth', - '\mod_scorm\analytics\indicator\social_breadth', - '\mod_survey\analytics\indicator\cognitive_depth', - '\mod_survey\analytics\indicator\social_breadth', - '\mod_url\analytics\indicator\cognitive_depth', - '\mod_url\analytics\indicator\social_breadth', - '\mod_wiki\analytics\indicator\cognitive_depth', - '\mod_wiki\analytics\indicator\social_breadth', - '\mod_workshop\analytics\indicator\cognitive_depth', - '\mod_workshop\analytics\indicator\social_breadth', - )); - $modelobj->version = $now; - $modelobj->timecreated = $now; - $modelobj->timemodified = $now; - $modelobj->usermodified = $admin->id; - $DB->insert_record('analytics_models', $modelobj); - } - - $targetname = '\core\analytics\target\no_teaching'; - if (!$DB->record_exists('analytics_models', array('target' => $targetname))) { - $modelobj = new stdClass(); - $modelobj->enabled = 1; - $modelobj->trained = 1; - $modelobj->target = $targetname; - $modelobj->indicators = json_encode(array('\core_course\analytics\indicator\no_teacher')); - $modelobj->timesplitting = '\core\analytics\time_splitting\single_range'; - $modelobj->version = $now; - $modelobj->timecreated = $now; - $modelobj->timemodified = $now; - $modelobj->usermodified = $admin->id; - $DB->insert_record('analytics_models', $modelobj); - } - // Main savepoint reached. upgrade_main_savepoint(true, 2017072000.02); } diff --git a/lib/upgradelib.php b/lib/upgradelib.php index b02579f5363..318d570ec33 100644 --- a/lib/upgradelib.php +++ b/lib/upgradelib.php @@ -578,6 +578,7 @@ function upgrade_plugins($type, $startcallback, $endcallback, $verbose) { log_update_descriptions($component); external_update_descriptions($component); \core\task\manager::reset_scheduled_tasks_for_component($component); + \core_analytics\manager::update_default_models_for_component($component); message_update_providers($component); \core\message\inbound\manager::update_handlers_for_component($component); if ($type === 'message') { @@ -616,6 +617,7 @@ function upgrade_plugins($type, $startcallback, $endcallback, $verbose) { log_update_descriptions($component); external_update_descriptions($component); \core\task\manager::reset_scheduled_tasks_for_component($component); + \core_analytics\manager::update_default_models_for_component($component); message_update_providers($component); \core\message\inbound\manager::update_handlers_for_component($component); if ($type === 'message') { @@ -649,6 +651,7 @@ function upgrade_plugins($type, $startcallback, $endcallback, $verbose) { log_update_descriptions($component); external_update_descriptions($component); \core\task\manager::reset_scheduled_tasks_for_component($component); + \core_analytics\manager::update_default_models_for_component($component); message_update_providers($component); \core\message\inbound\manager::update_handlers_for_component($component); if ($type === 'message') { @@ -756,6 +759,7 @@ function upgrade_plugins_modules($startcallback, $endcallback, $verbose) { log_update_descriptions($component); external_update_descriptions($component); \core\task\manager::reset_scheduled_tasks_for_component($component); + \core_analytics\manager::update_default_models_for_component($component); message_update_providers($component); \core\message\inbound\manager::update_handlers_for_component($component); upgrade_plugin_mnet_functions($component); @@ -790,6 +794,7 @@ function upgrade_plugins_modules($startcallback, $endcallback, $verbose) { log_update_descriptions($component); external_update_descriptions($component); \core\task\manager::reset_scheduled_tasks_for_component($component); + \core_analytics\manager::update_default_models_for_component($component); message_update_providers($component); \core\message\inbound\manager::update_handlers_for_component($component); upgrade_plugin_mnet_functions($component); @@ -826,6 +831,7 @@ function upgrade_plugins_modules($startcallback, $endcallback, $verbose) { log_update_descriptions($component); external_update_descriptions($component); \core\task\manager::reset_scheduled_tasks_for_component($component); + \core_analytics\manager::update_default_models_for_component($component); message_update_providers($component); \core\message\inbound\manager::update_handlers_for_component($component); upgrade_plugin_mnet_functions($component); @@ -947,6 +953,7 @@ function upgrade_plugins_blocks($startcallback, $endcallback, $verbose) { log_update_descriptions($component); external_update_descriptions($component); \core\task\manager::reset_scheduled_tasks_for_component($component); + \core_analytics\manager::update_default_models_for_component($component); message_update_providers($component); \core\message\inbound\manager::update_handlers_for_component($component); upgrade_plugin_mnet_functions($component); @@ -987,6 +994,7 @@ function upgrade_plugins_blocks($startcallback, $endcallback, $verbose) { log_update_descriptions($component); external_update_descriptions($component); \core\task\manager::reset_scheduled_tasks_for_component($component); + \core_analytics\manager::update_default_models_for_component($component); message_update_providers($component); \core\message\inbound\manager::update_handlers_for_component($component); core_tag_area::reset_definitions_for_component($component); @@ -1022,6 +1030,7 @@ function upgrade_plugins_blocks($startcallback, $endcallback, $verbose) { log_update_descriptions($component); external_update_descriptions($component); \core\task\manager::reset_scheduled_tasks_for_component($component); + \core_analytics\manager::update_default_models_for_component($component); message_update_providers($component); \core\message\inbound\manager::update_handlers_for_component($component); upgrade_plugin_mnet_functions($component); @@ -1738,6 +1747,7 @@ function install_core($version, $verbose) { log_update_descriptions('moodle'); external_update_descriptions('moodle'); \core\task\manager::reset_scheduled_tasks_for_component('moodle'); + \core_analytics\manager::update_default_models_for_component('moodle'); message_update_providers('moodle'); \core\message\inbound\manager::update_handlers_for_component('moodle'); core_tag_area::reset_definitions_for_component('moodle'); @@ -1805,6 +1815,7 @@ function upgrade_core($version, $verbose) { log_update_descriptions('moodle'); external_update_descriptions('moodle'); \core\task\manager::reset_scheduled_tasks_for_component('moodle'); + \core_analytics\manager::update_default_models_for_component('moodle'); message_update_providers('moodle'); \core\message\inbound\manager::update_handlers_for_component('moodle'); core_tag_area::reset_definitions_for_component('moodle');