diff --git a/admin/tool/analytics/amd/build/potential-contexts.min.js b/admin/tool/analytics/amd/build/potential-contexts.min.js new file mode 100644 index 00000000000..c93ee47896f --- /dev/null +++ b/admin/tool/analytics/amd/build/potential-contexts.min.js @@ -0,0 +1,2 @@ +define ("tool_analytics/potential-contexts",["jquery","core/ajax"],function(a,b){return{processResults:function processResults(b,c){var d=[];if(a.isArray(c)){a.each(c,function(a,b){d.push({value:b.id,label:b.name})});return d}else{return c}},transport:function transport(c,d,e,f){var g,h=a(c).attr("modelid")||null;g=b.call([{methodname:"tool_analytics_potential_contexts",args:{query:d,modelid:h}}]);g[0].then(e).fail(f)}}}); +//# sourceMappingURL=potential-contexts.min.js.map diff --git a/admin/tool/analytics/amd/build/potential-contexts.min.js.map b/admin/tool/analytics/amd/build/potential-contexts.min.js.map new file mode 100644 index 00000000000..03b6fb56a01 --- /dev/null +++ b/admin/tool/analytics/amd/build/potential-contexts.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/potential-contexts.js"],"names":["define","$","Ajax","processResults","selector","results","contexts","isArray","each","index","context","push","value","id","label","name","transport","query","success","failure","promise","modelid","attr","call","methodname","args","then","fail"],"mappings":"AAyBAA,OAAM,qCAAC,CAAC,QAAD,CAAW,WAAX,CAAD,CAA0B,SAASC,CAAT,CAAYC,CAAZ,CAAkB,CAE9C,MAA8D,CAE1DC,cAAc,CAAE,wBAASC,CAAT,CAAmBC,CAAnB,CAA4B,CACxC,GAAIC,CAAAA,CAAQ,CAAG,EAAf,CACA,GAAIL,CAAC,CAACM,OAAF,CAAUF,CAAV,CAAJ,CAAwB,CACpBJ,CAAC,CAACO,IAAF,CAAOH,CAAP,CAAgB,SAASI,CAAT,CAAgBC,CAAhB,CAAyB,CACrCJ,CAAQ,CAACK,IAAT,CAAc,CACVC,KAAK,CAAEF,CAAO,CAACG,EADL,CAEVC,KAAK,CAAEJ,CAAO,CAACK,IAFL,CAAd,CAIH,CALD,EAMA,MAAOT,CAAAA,CAEV,CATD,IASO,CACH,MAAOD,CAAAA,CACV,CACJ,CAhByD,CAkB1DW,SAAS,CAAE,mBAASZ,CAAT,CAAmBa,CAAnB,CAA0BC,CAA1B,CAAmCC,CAAnC,CAA4C,IAC/CC,CAAAA,CAD+C,CAG/CC,CAAO,CAAGpB,CAAC,CAACG,CAAD,CAAD,CAAYkB,IAAZ,CAAiB,SAAjB,GAA+B,IAHM,CAInDF,CAAO,CAAGlB,CAAI,CAACqB,IAAL,CAAU,CAAC,CACjBC,UAAU,CAAE,mCADK,CAEjBC,IAAI,CAAE,CACFR,KAAK,CAAEA,CADL,CAEFI,OAAO,CAAEA,CAFP,CAFW,CAAD,CAAV,CAAV,CAQAD,CAAO,CAAC,CAAD,CAAP,CAAWM,IAAX,CAAgBR,CAAhB,EAAyBS,IAAzB,CAA8BR,CAA9B,CACH,CA/ByD,CAmCjE,CArCK,CAAN","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Potential contexts selector module.\n *\n * @module tool_analytics/potential-contexts\n * @class potential-contexts\n * @package tool_analytics\n * @copyright 2019 David Monllao\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\ndefine(['jquery', 'core/ajax'], function($, Ajax) {\n\n return /** @alias module:tool_analytics/potential-contexts */ {\n\n processResults: function(selector, results) {\n var contexts = [];\n if ($.isArray(results)) {\n $.each(results, function(index, context) {\n contexts.push({\n value: context.id,\n label: context.name\n });\n });\n return contexts;\n\n } else {\n return results;\n }\n },\n\n transport: function(selector, query, success, failure) {\n var promise;\n\n let modelid = $(selector).attr('modelid') || null;\n promise = Ajax.call([{\n methodname: 'tool_analytics_potential_contexts',\n args: {\n query: query,\n modelid: modelid\n }\n }]);\n\n promise[0].then(success).fail(failure);\n }\n\n };\n\n});\n"],"file":"potential-contexts.min.js"} \ No newline at end of file diff --git a/admin/tool/analytics/amd/src/potential-contexts.js b/admin/tool/analytics/amd/src/potential-contexts.js new file mode 100644 index 00000000000..eb353675e6c --- /dev/null +++ b/admin/tool/analytics/amd/src/potential-contexts.js @@ -0,0 +1,63 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Potential contexts selector module. + * + * @module tool_analytics/potential-contexts + * @class potential-contexts + * @package tool_analytics + * @copyright 2019 David Monllao + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +define(['jquery', 'core/ajax'], function($, Ajax) { + + return /** @alias module:tool_analytics/potential-contexts */ { + + processResults: function(selector, results) { + var contexts = []; + if ($.isArray(results)) { + $.each(results, function(index, context) { + contexts.push({ + value: context.id, + label: context.name + }); + }); + return contexts; + + } else { + return results; + } + }, + + transport: function(selector, query, success, failure) { + var promise; + + let modelid = $(selector).attr('modelid') || null; + promise = Ajax.call([{ + methodname: 'tool_analytics_potential_contexts', + args: { + query: query, + modelid: modelid + } + }]); + + promise[0].then(success).fail(failure); + } + + }; + +}); diff --git a/admin/tool/analytics/classes/external.php b/admin/tool/analytics/classes/external.php new file mode 100644 index 00000000000..515c08a63dd --- /dev/null +++ b/admin/tool/analytics/classes/external.php @@ -0,0 +1,113 @@ +. + +/** + * This is the external API for this component. + * + * @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 + */ + +namespace tool_analytics; + +defined('MOODLE_INTERNAL') || die(); + +require_once("$CFG->libdir/externallib.php"); + +use external_api; +use external_function_parameters; +use external_value; +use external_single_structure; +use external_multiple_structure; + +/** + * This is the external API for this component. + * + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class external extends external_api { + + const MAX_CONTEXTS_RETURNED = 100; + + /** + * potential_contexts parameters. + * + * @since Moodle 3.8 + * @return external_function_parameters + */ + public static function potential_contexts_parameters() { + return new external_function_parameters( + array( + 'query' => new external_value(PARAM_NOTAGS, 'The model id', VALUE_DEFAULT), + 'modelid' => new external_value(PARAM_INT, 'The model id', VALUE_DEFAULT) + ) + ); + } + + /** + * Return the contexts that match the provided query. + * + * @since Moodle 3.8 + * @param string|null $query + * @param int|null $modelid + * @return array an array of contexts + */ + public static function potential_contexts(?string $query = null, ?int $modelid = null) { + + $params = self::validate_parameters(self::potential_contexts_parameters(), ['modelid' => $modelid, 'query' => $query]); + + \core_analytics\manager::check_can_manage_models(); + + if ($params['modelid']) { + $model = new \core_analytics\model($params['modelid']); + $contexts = ($model->get_analyser(['notimesplitting' => true]))::potential_context_restrictions($params['query']); + } else { + $contexts = \core_analytics\manager::get_potential_context_restrictions(null, $params['query']); + } + + $contextoptions = []; + $i = 0; + foreach ($contexts as $contextid => $contextname) { + + if ($i === self::MAX_CONTEXTS_RETURNED) { + // Limited to MAX_CONTEXTS_RETURNED items. + break; + } + + $contextoptions[] = ['id' => $contextid, 'name' => $contextname]; + $i++; + } + + return $contextoptions; + } + + /** + * potential_contexts return + * + * @since Moodle 3.8 + * @return external_description + */ + public static function potential_contexts_returns() { + return new external_multiple_structure( + new external_single_structure([ + 'id' => new external_value(PARAM_INT, 'ID of the context'), + 'name' => new external_value(PARAM_NOTAGS, 'The context name') + ]) + ); + } +} diff --git a/admin/tool/analytics/classes/output/form/edit_model.php b/admin/tool/analytics/classes/output/form/edit_model.php index c897b5c81bc..b9515cdda2f 100644 --- a/admin/tool/analytics/classes/output/form/edit_model.php +++ b/admin/tool/analytics/classes/output/form/edit_model.php @@ -105,6 +105,28 @@ class edit_model extends \moodleform { $mform->addElement('select', 'timesplitting', get_string('timesplittingmethod', 'analytics'), $timesplittings); $mform->addHelpButton('timesplitting', 'timesplittingmethod', 'analytics'); + // Contexts restriction. + if (!empty($this->_customdata['supportscontexts'])) { + + $options = [ + 'ajax' => 'tool_analytics/potential-contexts', + 'multiple' => true, + 'noselectionstring' => get_string('all') + ]; + + if (!empty($this->_customdata['id'])) { + $options['modelid'] = $this->_customdata['id']; + $contexts = $this->load_current_contexts(); + } else { + // No need to preload any selected contexts. + $contexts = []; + } + + $mform->addElement('autocomplete', 'contexts', get_string('contexts', 'tool_analytics'), $contexts, $options); + $mform->setType('contexts', PARAM_INT); + $mform->addHelpButton('contexts', 'contexts', 'tool_analytics'); + } + // Predictions processor. if (!$this->_customdata['staticmodel']) { $defaultprocessor = \core_analytics\manager::get_predictions_processor_name( @@ -146,20 +168,37 @@ class edit_model extends \moodleform { public function validation($data, $files) { $errors = parent::validation($data, $files); + $targetclass = \tool_analytics\output\helper::option_to_class($data['target']); + $target = \core_analytics\manager::get_target($targetclass); + if (!empty($data['timesplitting'])) { $timesplittingclass = \tool_analytics\output\helper::option_to_class($data['timesplitting']); if (\core_analytics\manager::is_valid($timesplittingclass, '\core_analytics\local\time_splitting\base') === false) { $errors['timesplitting'] = get_string('errorinvalidtimesplitting', 'analytics'); } - $targetclass = \tool_analytics\output\helper::option_to_class($data['target']); $timesplitting = \core_analytics\manager::get_time_splitting($timesplittingclass); - $target = \core_analytics\manager::get_target($targetclass); if (!$target->can_use_timesplitting($timesplitting)) { $errors['timesplitting'] = get_string('invalidtimesplitting', 'tool_analytics'); } } + if (!empty($data['contexts'])) { + + $analyserclass = $target->get_analyser_class(); + if (!$potentialcontexts = $analyserclass::potential_context_restrictions()) { + $errors['contexts'] = get_string('errornocontextrestrictions', 'analytics'); + } else { + + // Flip the contexts array so we can just diff by key. + $selectedcontexts = array_flip($data['contexts']); + $invalidcontexts = array_diff_key($selectedcontexts, $potentialcontexts); + if (!empty($invalidcontexts)) { + $errors['contexts'] = get_string('errorinvalidcontexts', 'analytics'); + } + } + } + if (!$this->_customdata['staticmodel']) { if (empty($data['indicators'])) { $errors['indicators'] = get_string('errornoindicators', 'analytics'); @@ -179,4 +218,18 @@ class edit_model extends \moodleform { return $errors; } + + /** + * Load the currently selected context options. + * + * @return array + */ + protected function load_current_contexts() { + $contexts = []; + foreach ($this->_customdata['contexts'] as $context) { + $contexts[$context->id] = $context->get_context_name(true, true); + } + + return $contexts; + } } diff --git a/admin/tool/analytics/classes/output/invalid_analysables.php b/admin/tool/analytics/classes/output/invalid_analysables.php index fd205f1dd8e..201175493a3 100644 --- a/admin/tool/analytics/classes/output/invalid_analysables.php +++ b/admin/tool/analytics/classes/output/invalid_analysables.php @@ -76,7 +76,8 @@ class invalid_analysables implements \renderable, \templatable { $offset = $this->page * $this->perpage; - $analysables = $this->model->get_analyser(['notimesplitting' => true])->get_analysables_iterator(); + $contexts = $this->model->get_contexts(); + $analysables = $this->model->get_analyser(['notimesplitting' => true])->get_analysables_iterator(null, $contexts); $skipped = 0; $enoughresults = false; diff --git a/admin/tool/analytics/cli/evaluate_model.php b/admin/tool/analytics/cli/evaluate_model.php index 3094ffaaec2..7d07b6ffe05 100644 --- a/admin/tool/analytics/cli/evaluate_model.php +++ b/admin/tool/analytics/cli/evaluate_model.php @@ -34,7 +34,6 @@ Options: --list List models --non-interactive Not interactive questions --analysisinterval Restrict the evaluation to 1 single analysis interval (Optional) ---filter Analyser dependant. e.g. A courseid would evaluate the model using a single course (Optional) --mode 'configuration' or 'trainedmodel'. You can only use mode=trainedmodel when the trained" . " model was imported" . " --reuse-prev-analysed Reuse recently analysed courses instead of analysing the whole site. Set it to false while" . @@ -42,7 +41,7 @@ Options: -h, --help Print out this help Example: -\$ php admin/tool/analytics/cli/evaluate_model.php --modelid=1 --analysisinterval='\\core\\analytics\\time_splitting\\quarters' --filter=123,321 +\$ php admin/tool/analytics/cli/evaluate_model.php --modelid=1 --analysisinterval='\\core\\analytics\\time_splitting\\quarters' "; // Now get cli options. @@ -55,7 +54,6 @@ list($options, $unrecognized) = cli_get_params( 'mode' => 'configuration', 'reuse-prev-analysed' => true, 'non-interactive' => false, - 'filter' => false ), array( 'h' => 'help', @@ -83,11 +81,6 @@ if ($options['modelid'] === false) { exit(0); } -// Reformat them as an array. -if ($options['filter'] !== false) { - $options['filter'] = explode(',', $options['filter']); -} - if ($options['mode'] !== 'configuration' && $options['mode'] !== 'trainedmodel') { cli_error('Error: The provided mode is not supported'); } @@ -110,7 +103,6 @@ if ($options['reuse-prev-analysed']) { $renderer = $PAGE->get_renderer('tool_analytics'); $analyseroptions = array( - 'filter' => $options['filter'], 'timesplitting' => $options['analysisinterval'], 'reuseprevanalysed' => $options['reuse-prev-analysed'], 'mode' => $options['mode'], diff --git a/admin/tool/analytics/createmodel.php b/admin/tool/analytics/createmodel.php index ad4fc836e32..678eec8201b 100644 --- a/admin/tool/analytics/createmodel.php +++ b/admin/tool/analytics/createmodel.php @@ -45,6 +45,8 @@ $targets = array_filter(\core_analytics\manager::get_all_targets(), function($ta return (!$target->based_on_assumptions()); }); +// Set 'supportscontexts' to true as at this stage we don't know if the contexts are supported by +// the selected target. $customdata = array( 'trainedmodel' => false, 'staticmodel' => false, @@ -52,6 +54,7 @@ $customdata = array( 'indicators' => \core_analytics\manager::get_all_indicators(), 'timesplittings' => \core_analytics\manager::get_all_time_splittings(), 'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors(), + 'supportscontexts' => true, ); $mform = new \tool_analytics\output\form\edit_model(null, $customdata); @@ -86,8 +89,8 @@ if ($mform->is_cancelled()) { $indicators = array_diff_key($indicators, $invalidindicators); } - // Update the model with the valid list of indicators. - $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor); + // Update the model with the rest of the data provided in the form. + $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor, $data->contexts); $message = ''; $messagetype = \core\output\notification::NOTIFY_SUCCESS; diff --git a/admin/tool/analytics/db/services.php b/admin/tool/analytics/db/services.php new file mode 100644 index 00000000000..fefbfb8df19 --- /dev/null +++ b/admin/tool/analytics/db/services.php @@ -0,0 +1,37 @@ +. + +/** + * Tool analytics webservice definitions. + * + * @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 + */ + +defined('MOODLE_INTERNAL') || die(); + +$functions = array( + + 'tool_analytics_potential_contexts' => array( + 'classname' => 'tool_analytics\external', + 'methodname' => 'potential_contexts', + 'description' => 'Retrieve the list of potential contexts for a model.', + 'type' => 'read', + 'ajax' => true, + 'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE) + ), +); diff --git a/admin/tool/analytics/lang/en/tool_analytics.php b/admin/tool/analytics/lang/en/tool_analytics.php index ca3d1a271a1..acace09273b 100644 --- a/admin/tool/analytics/lang/en/tool_analytics.php +++ b/admin/tool/analytics/lang/en/tool_analytics.php @@ -45,6 +45,8 @@ $string['component'] = 'Component'; $string['componentcore'] = 'Core'; $string['componentselect'] = 'Select all models provided by the component \'{$a}\''; $string['componentselectnone'] = 'Unselect all'; +$string['contexts'] = 'Contexts'; +$string['contexts_help'] = 'The model will be limited to this set of contexts. No context restrictions will be applied if no contexts are selected.'; $string['createmodel'] = 'Create model'; $string['currenttimesplitting'] = 'Current analysis interval'; $string['delete'] = 'Delete'; diff --git a/admin/tool/analytics/model.php b/admin/tool/analytics/model.php index 4886ff5ffb0..3788f150731 100644 --- a/admin/tool/analytics/model.php +++ b/admin/tool/analytics/model.php @@ -122,6 +122,7 @@ switch ($action) { $invalidcurrenttimesplitting = $model->invalid_timesplitting_selected(); $potentialtimesplittings = $model->get_potential_timesplittings(); + $analyser = $model->get_analyser(); $customdata = array( 'id' => $model->get_id(), @@ -132,7 +133,9 @@ switch ($action) { 'targetname' => $model->get_target()->get_name(), 'indicators' => $model->get_potential_indicators(), 'timesplittings' => $potentialtimesplittings, - 'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors() + 'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors(), + 'supportscontexts' => ($analyser)::context_restriction_support(), + 'contexts' => $model->get_contexts(), ); $mform = new \tool_analytics\output\form\edit_model(null, $customdata); @@ -157,7 +160,7 @@ switch ($action) { $predictionsprocessor = false; } - $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor); + $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor, $data->contexts); redirect($returnurl); } @@ -168,6 +171,9 @@ switch ($action) { $callable = array('\tool_analytics\output\helper', 'class_to_option'); $modelobj->indicators = array_map($callable, json_decode($modelobj->indicators)); $modelobj->timesplitting = \tool_analytics\output\helper::class_to_option($modelobj->timesplitting); + if ($modelobj->contextids) { + $modelobj->contexts = array_map($callable, json_decode($modelobj->contextids)); + } $modelobj->predictionsprocessor = \tool_analytics\output\helper::class_to_option($modelobj->predictionsprocessor); $mform->set_data($modelobj); $mform->display(); diff --git a/admin/tool/analytics/tests/external_test.php b/admin/tool/analytics/tests/external_test.php new file mode 100644 index 00000000000..ff26c646b8c --- /dev/null +++ b/admin/tool/analytics/tests/external_test.php @@ -0,0 +1,80 @@ +. + +/** + * Tool analytics external functions tests. + * + * @package tool_analytics + * @category external + * @copyright 2019 David MonllaĆ³ {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @since Moodle 3.8 + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; + +require_once($CFG->dirroot . '/webservice/tests/helpers.php'); +require_once(__DIR__ . '/../../../../analytics/tests/fixtures/test_indicator_max.php'); +require_once(__DIR__ . '/../../../../analytics/tests/fixtures/test_target_course_level_shortname.php'); + +/** + * Tool analytics external functions tests + * + * @package tool_analytics + * @category external + * @copyright 2019 David MonllaĆ³ {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @since Moodle 3.8 + */ +class tool_analytics_external_testcase extends externallib_advanced_testcase { + + /** + * test_potential_contexts description + */ + public function test_potential_contexts() { + $this->resetAfterTest(); + + $this->setAdminUser(); + + // Include the all context levels so the misc. category get included. + $this->assertCount(1, \tool_analytics\external::potential_contexts()); + + // The frontpage is not included. + $this->assertCount(0, \tool_analytics\external::potential_contexts('PHPUnit')); + + $target = \core_analytics\manager::get_target('test_target_course_level_shortname'); + $indicators = ['test_indicator_max' => \core_analytics\manager::get_indicator('test_indicator_max')]; + $model = \core_analytics\model::create($target, $indicators); + + $this->assertCount(1, \tool_analytics\external::potential_contexts(null, $model->get_id())); + } + + /** + * test_potential_contexts description + * + * @expectedException required_capability_exception + */ + public function test_potential_contexts_no_manager() { + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + $this->assertCount(2, \tool_analytics\external::potential_contexts()); + } +} diff --git a/admin/tool/analytics/version.php b/admin/tool/analytics/version.php index 03c5187f185..c2e2b1c791d 100644 --- a/admin/tool/analytics/version.php +++ b/admin/tool/analytics/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2019052000; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2019052002; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2019051100; // Requires this Moodle version. $plugin->component = 'tool_analytics'; // Full name of the plugin (used for diagnostics). diff --git a/analytics/classes/analysis.php b/analytics/classes/analysis.php index ad39b48d0ac..f11bfae03d7 100644 --- a/analytics/classes/analysis.php +++ b/analytics/classes/analysis.php @@ -75,9 +75,10 @@ class analysis { /** * Runs the analysis. * + * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null. * @return null */ - public function run() { + public function run(array $contexts = []) { $options = $this->analyser->get_options(); @@ -89,7 +90,7 @@ class analysis { } else { $action = 'prediction'; } - $analysables = $this->analyser->get_analysables_iterator($action); + $analysables = $this->analyser->get_analysables_iterator($action, $contexts); $processedanalysables = $this->get_processed_analysables(); diff --git a/analytics/classes/local/analyser/base.php b/analytics/classes/local/analyser/base.php index 0065f15ab85..99735bd2c45 100644 --- a/analytics/classes/local/analyser/base.php +++ b/analytics/classes/local/analyser/base.php @@ -131,9 +131,10 @@ abstract class base { * to ease to implementation of get_analysables_iterator: get_iterator_sql and order_sql. * * @param string|null $action 'prediction', 'training' or null if no specific action needed. + * @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty. * @return \Iterator */ - public function get_analysables_iterator(?string $action = null) { + public function get_analysables_iterator(?string $action = null, array $contexts = []) { debugging('Please overwrite get_analysables_iterator with your own implementation, we only keep this default implementation for backwards compatibility purposes with get_analysables(). note that $action param will @@ -266,38 +267,42 @@ abstract class base { /** * Returns labelled data (training and evaluation). * + * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null. * @return \stored_file[] */ - public function get_labelled_data() { + public function get_labelled_data(array $contexts = []) { // Delegates all processing to the analysis. $result = new \core_analytics\local\analysis\result_file($this->get_modelid(), true, $this->get_options()); $analysis = new \core_analytics\analysis($this, true, $result); - $analysis->run(); + $analysis->run($contexts); return $result->get(); } /** * Returns unlabelled data (prediction). * + * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null. * @return \stored_file[] */ - public function get_unlabelled_data() { + public function get_unlabelled_data(array $contexts = []) { // Delegates all processing to the analysis. $result = new \core_analytics\local\analysis\result_file($this->get_modelid(), false, $this->get_options()); $analysis = new \core_analytics\analysis($this, false, $result); - $analysis->run(); + $analysis->run($contexts); return $result->get(); } /** * Returns indicator calculations as an array. + * + * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null. * @return array */ - public function get_static_data() { + public function get_static_data(array $contexts = []) { // Delegates all processing to the analysis. $result = new \core_analytics\local\analysis\result_array($this->get_modelid(), false, $this->get_options()); $analysis = new \core_analytics\analysis($this, false, $result); - $analysis->run(); + $analysis->run($contexts); return $result->get(); } @@ -422,6 +427,34 @@ abstract class base { return false; } + /** + * Returns an array of context levels that can be used to restrict the contexts used during analysis. + * + * The contexts provided to self::get_analysables_iterator will match these contextlevels. + * + * @return array Array of context levels or an empty array if context restriction is not supported. + */ + public static function context_restriction_support(): array { + return []; + } + + /** + * Returns the possible contexts used by the analyser. + * + * This method uses separate logic for each context level because to iterate through + * the list of contexts calling get_context_name for each of them would be expensive + * in performance terms. + * + * This generic implementation returns all the contexts in the site for the provided context level. + * Overwrite it for specific restrictions in your analyser. + * + * @param string|null $query Context name filter. + * @return int[] + */ + public static function potential_context_restrictions(string $query = null) { + return \core_analytics\manager::get_potential_context_restrictions(static::context_restriction_support(), $query); + } + /** * Get the sql of a default implementation of the iterator. * @@ -431,9 +464,12 @@ abstract class base { * @param int $contextlevel The context level of the analysable * @param string|null $action * @param string|null $tablealias The table alias + * @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables if empty. * @return array [0] => sql and [1] => params array */ - protected function get_iterator_sql(string $tablename, int $contextlevel, ?string $action = null, ?string $tablealias = null) { + protected function get_iterator_sql(string $tablename, int $contextlevel, ?string $action = null, ?string $tablealias = null, + array $contexts = []) { + global $DB; if (!$tablealias) { $tablealias = 'analysable'; @@ -452,13 +488,30 @@ abstract class base { $params = $params + ['action' => $action]; } - // Adding the 1 = 1 just to have the WHERE part so that all further conditions added by callers can be - // appended to $sql with and ' AND'. $sql = 'SELECT ' . $select . ' FROM {' . $tablename . '} ' . $tablealias . ' ' . $usedanalysablesjoin . ' - JOIN {context} ctx ON (ctx.contextlevel = :contextlevel AND ctx.instanceid = ' . $tablealias . '.id) - WHERE 1 = 1'; + JOIN {context} ctx ON (ctx.contextlevel = :contextlevel AND ctx.instanceid = ' . $tablealias . '.id) '; + + if (!$contexts) { + // Adding the 1 = 1 just to have the WHERE part so that all further conditions + // added by callers can be appended to $sql with and ' AND'. + $sql .= 'WHERE 1 = 1'; + } else { + + $contextsqls = []; + foreach ($contexts as $context) { + $paramkey1 = 'paramctxlike' . $context->id; + $paramkey2 = 'paramctxeq' . $context->id; + $contextsqls[] = $DB->sql_like('ctx.path', ':' . $paramkey1); + $contextsqls[] = 'ctx.path = :' . $paramkey2; + + // This includes the context itself. + $params[$paramkey1] = $context->path . '/%'; + $params[$paramkey2] = $context->path; + } + $sql .= 'WHERE (' . implode(' OR ', $contextsqls) . ')'; + } return [$sql, $params]; } diff --git a/analytics/classes/local/analyser/by_course.php b/analytics/classes/local/analyser/by_course.php index c0d308b0d70..59b66273605 100644 --- a/analytics/classes/local/analyser/by_course.php +++ b/analytics/classes/local/analyser/by_course.php @@ -39,24 +39,13 @@ abstract class by_course extends base { * Return the list of courses to analyse. * * @param string|null $action 'prediction', 'training' or null if no specific action needed. + * @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty. * @return \Iterator */ - public function get_analysables_iterator(?string $action = null) { + public function get_analysables_iterator(?string $action = null, array $contexts = []) { global $DB; - list($sql, $params) = $this->get_iterator_sql('course', CONTEXT_COURSE, $action, 'c'); - - // This will be updated to filter by context as part of MDL-64739. - if (!empty($this->options['filter'])) { - $courses = array(); - foreach ($this->options['filter'] as $courseid) { - $courses[$courseid] = intval($courseid); - } - - list($coursesql, $courseparams) = $DB->get_in_or_equal($courses, SQL_PARAMS_NAMED); - $sql .= " AND c.id $coursesql"; - $params = $params + $courseparams; - } + list($sql, $params) = $this->get_iterator_sql('course', CONTEXT_COURSE, $action, 'c', $contexts); $ordersql = $this->order_sql('sortorder', 'ASC', 'c'); @@ -76,4 +65,13 @@ abstract class by_course extends base { return \core_analytics\course::instance($record, $context); }); } -} \ No newline at end of file + + /** + * Can be limited to course categories or specific courses. + * + * @return array + */ + public static function context_restriction_support(): array { + return [CONTEXT_COURSE, CONTEXT_COURSECAT]; + } +} diff --git a/analytics/classes/local/analyser/sitewide.php b/analytics/classes/local/analyser/sitewide.php index da6ea71db82..28a4db3a4e9 100644 --- a/analytics/classes/local/analyser/sitewide.php +++ b/analytics/classes/local/analyser/sitewide.php @@ -39,9 +39,10 @@ abstract class sitewide extends base { * Return the list of analysables to analyse. * * @param string|null $action 'prediction', 'training' or null if no specific action needed. + * @param \context[] $contexts Ignored here. * @return \Iterator */ - public function get_analysables_iterator(?string $action = null) { + public function get_analysables_iterator(?string $action = null, array $contexts = []) { // We can safely ignore $action as we have 1 single analysable element in this analyser. return new \ArrayIterator([new \core_analytics\site()]); } diff --git a/analytics/classes/manager.php b/analytics/classes/manager.php index 60f1f4597fd..131c782d582 100644 --- a/analytics/classes/manager.php +++ b/analytics/classes/manager.php @@ -646,6 +646,8 @@ class manager { $usedanalysablesanalysableids = array_flip($usedanalysablesanalysableids); $analyser = $model->get_analyser(array('notimesplitting' => true)); + + // We do not honour the list of contexts in this model as it can contain stale records. $analysables = $analyser->get_analysables_iterator(); $analysableids = []; @@ -913,4 +915,79 @@ class manager { return [$target, $indicators]; } + + /** + * Return the context restrictions that can be applied to the provided context levels. + * + * @throws \coding_exception + * @param array|null $contextlevels The list of context levels provided by the analyser. Null if all of them. + * @param string|null $query + * @return array Associative array with contextid as key and the short version of the context name as value. + */ + public static function get_potential_context_restrictions(?array $contextlevels = null, string $query = null) { + global $DB; + + if (empty($contextlevels) && !is_null($contextlevels)) { + return false; + } + + if (!is_null($contextlevels)) { + foreach ($contextlevels as $contextlevel) { + if ($contextlevel !== CONTEXT_COURSE && $contextlevel !== CONTEXT_COURSECAT) { + throw new \coding_exception('Only CONTEXT_COURSE and CONTEXT_COURSECAT are supported at the moment.'); + } + } + } + + $contexts = []; + + // We have a separate process for each context level for performance reasons (to iterate through mdl_context calling + // get_context_name() would be too slow). + $contextsystem = \context_system::instance(); + if (is_null($contextlevels) || in_array(CONTEXT_COURSECAT, $contextlevels)) { + + $sql = "SELECT cc.id, cc.name, ctx.id AS contextid + FROM {course_categories} cc + JOIN {context} ctx ON ctx.contextlevel = :ctxlevel AND ctx.instanceid = cc.id"; + $params = ['ctxlevel' => CONTEXT_COURSECAT]; + + if ($query) { + $sql .= " WHERE " . $DB->sql_like('cc.name', ':query', false, false); + $params['query'] = '%' . $query . '%'; + } + + $coursecats = $DB->get_recordset_sql($sql, $params); + foreach ($coursecats as $record) { + $contexts[$record->contextid] = get_string('category') . ': ' . + format_string($record->name, true, array('context' => $contextsystem)); + } + $coursecats->close(); + } + + if (is_null($contextlevels) || in_array(CONTEXT_COURSE, $contextlevels)) { + + $sql = "SELECT c.id, c.shortname, ctx.id AS contextid + FROM {course} c + JOIN {context} ctx ON ctx.contextlevel = :ctxlevel AND ctx.instanceid = c.id + WHERE c.id != :siteid"; + $params = ['ctxlevel' => CONTEXT_COURSE, 'siteid' => SITEID]; + + if ($query) { + $sql .= ' AND (' . $DB->sql_like('c.fullname', ':query1', false, false) . ' OR ' . + $DB->sql_like('c.shortname', ':query2', false, false) . ')'; + $params['query1'] = '%' . $query . '%'; + $params['query2'] = '%' . $query . '%'; + } + + $courses = $DB->get_recordset_sql($sql, $params); + foreach ($courses as $record) { + $contexts[$record->contextid] = get_string('course') . ': ' . + format_string($record->shortname, true, array('context' => $contextsystem)); + } + $courses->close(); + } + + return $contexts; + } + } diff --git a/analytics/classes/model.php b/analytics/classes/model.php index 9a524be4c63..cc92516b297 100644 --- a/analytics/classes/model.php +++ b/analytics/classes/model.php @@ -120,6 +120,11 @@ class model { */ protected $indicators = null; + /** + * @var \context[] + */ + protected $contexts = null; + /** * Unique Model id created from site info and last model modification. * @@ -459,9 +464,11 @@ class model { * @param \core_analytics\local\indicator\base[]|false $indicators False to respect current indicators * @param string|false $timesplittingid False to respect current time splitting method * @param string|false $predictionsprocessor False to respect current predictors processor value + * @param int[]|false $contextids List of context ids for this model. False to respect the current list of contexts. * @return void */ - public function update($enabled, $indicators = false, $timesplittingid = '', $predictionsprocessor = false) { + public function update($enabled, $indicators = false, $timesplittingid = '', $predictionsprocessor = false, + $contextids = false) { global $USER, $DB; \core_analytics\manager::check_can_manage_models(); @@ -486,6 +493,15 @@ class model { $predictionsprocessor = $this->model->predictionsprocessor; } + if ($contextids !== false) { + $contextsstr = json_encode($contextids); + + // Reset the internal cache. + $this->contexts = null; + } else { + $contextsstr = $this->model->contextids; + } + if ($this->model->timesplitting !== $timesplittingid || $this->model->indicators !== $indicatorsstr || $this->model->predictionsprocessor !== $predictionsprocessor) { @@ -514,6 +530,7 @@ class model { $this->model->indicators = $indicatorsstr; $this->model->timesplitting = $timesplittingid; $this->model->predictionsprocessor = $predictionsprocessor; + $this->model->contextids = $contextsstr; $this->model->timemodified = $now; $this->model->usermodified = $USER->id; @@ -603,7 +620,7 @@ class model { // Before get_labelled_data call so we get an early exception if it is not ready. $predictor = $this->get_predictions_processor(); - $datasets = $this->get_analyser()->get_labelled_data(); + $datasets = $this->get_analyser()->get_labelled_data($this->get_contexts()); // No datasets generated. if (empty($datasets)) { @@ -695,7 +712,7 @@ class model { // Before get_labelled_data call so we get an early exception if it is not ready. $predictor = $this->get_predictions_processor(); - $datasets = $this->get_analyser()->get_labelled_data(); + $datasets = $this->get_analyser()->get_labelled_data($this->get_contexts()); // No training if no files have been provided. if (empty($datasets) || empty($datasets[$this->model->timesplitting])) { @@ -766,7 +783,7 @@ class model { // Before get_unlabelled_data call so we get an early exception if it is not ready. $predictor = $this->get_predictions_processor(); - $samplesdata = $this->get_analyser()->get_unlabelled_data(); + $samplesdata = $this->get_analyser()->get_unlabelled_data($this->get_contexts()); // Get the prediction samples file. if (empty($samplesdata) || empty($samplesdata[$this->model->timesplitting])) { @@ -802,7 +819,7 @@ class model { } else { // Predictions based on assumptions. - $indicatorcalculations = $this->get_analyser()->get_static_data(); + $indicatorcalculations = $this->get_analyser()->get_static_data($this->get_contexts()); // Get the prediction samples file. if (empty($indicatorcalculations) || empty($indicatorcalculations[$this->model->timesplitting])) { @@ -1956,6 +1973,30 @@ class model { return [$allsampleids, $allsamplesdata]; } + /** + * Contexts where this model should be active. + * + * @return \context[] Empty array if there are no context restrictions. + */ + public function get_contexts() { + if ($this->contexts !== null) { + return $this->contexts; + } + + if (!$this->model->contextids) { + $this->contexts = []; + return $this->contexts; + } + $contextids = json_decode($this->model->contextids); + + // We don't expect this list to be massive as contexts need to be selected manually using the edit model form. + $this->contexts = array_map(function($contextid) { + return \context::instance_by_id($contextid, IGNORE_MISSING); + }, $contextids); + + return $this->contexts; + } + /** * Purges the insights cache. */ diff --git a/analytics/tests/manager_test.php b/analytics/tests/manager_test.php index 02166a8f65b..582c45740a2 100644 --- a/analytics/tests/manager_test.php +++ b/analytics/tests/manager_test.php @@ -487,4 +487,31 @@ class analytics_manager_testcase extends advanced_testcase { $this->assertNotEmpty($indicators); $this->assertContainsOnlyInstancesOf(\core_analytics\local\indicator\base::class, $indicators); } + + /** + * test_get_potential_context_restrictions description + */ + public function test_get_potential_context_restrictions() { + $this->resetAfterTest(); + + // No potential context restrictions. + $this->assertFalse(\core_analytics\manager::get_potential_context_restrictions([])); + + // Include the all context levels so the misc. category get included. + $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions()); + + $this->getDataGenerator()->create_course(); + $this->getDataGenerator()->create_category(); + $this->assertCount(3, \core_analytics\manager::get_potential_context_restrictions()); + $this->assertCount(3, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE, CONTEXT_COURSECAT])); + + $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE])); + $this->assertCount(2, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT])); + + $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'Course category')); + $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'Course category 1')); + $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'Miscellaneous')); + $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE], 'Test course 1')); + $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE], 'Test course')); + } } diff --git a/analytics/tests/prediction_test.php b/analytics/tests/prediction_test.php index 2b437f6270c..1fd9ac2a724 100644 --- a/analytics/tests/prediction_test.php +++ b/analytics/tests/prediction_test.php @@ -126,6 +126,57 @@ class core_analytics_prediction_testcase extends advanced_testcase { array('modelid' => $model->get_id()))); } + /** + * test_model_contexts + */ + public function test_model_contexts() { + global $DB; + + $this->resetAfterTest(true); + $this->setAdminuser(); + + $misc = $DB->get_record('course_categories', ['name' => 'Miscellaneous']); + $miscctx = \context_coursecat::instance($misc->id); + + $category = $this->getDataGenerator()->create_category(); + $categoryctx = \context_coursecat::instance($category->id); + + // One course per category. + $courseparams = array('shortname' => 'aaaaaa', 'fullname' => 'aaaaaa', 'visible' => 0, + 'category' => $category->id); + $course1 = $this->getDataGenerator()->create_course($courseparams); + $course1ctx = \context_course::instance($course1->id); + $courseparams = array('shortname' => 'bbbbbb', 'fullname' => 'bbbbbb', 'visible' => 0, + 'category' => $misc->id); + $course2 = $this->getDataGenerator()->create_course($courseparams); + + $model = $this->add_perfect_model('test_static_target_shortname'); + + // Just 1 category. + $model->update(true, false, '\core\analytics\time_splitting\no_splitting', false, [$categoryctx->id]); + $this->assertCount(1, $model->predict()->predictions); + + // Now with 2 categories. + $model->update(true, false, false, false, [$categoryctx->id, $miscctx->id]); + + // The courses in the new category are processed. + $this->assertCount(1, $model->predict()->predictions); + + // Clear the predictions generated by the model and predict() again. + $model->clear(); + $this->assertCount(2, $model->predict()->predictions); + + // Course context restriction. + $model->update(true, false, '\core\analytics\time_splitting\no_splitting', false, [$course1ctx->id]); + + // Nothing new as the course was already analysed. + $result = $model->predict(); + $this->assertTrue(empty($result->predictions)); + + $model->clear(); + $this->assertCount(1, $model->predict()->predictions); + } + /** * test_ml_training_and_prediction * diff --git a/analytics/upgrade.txt b/analytics/upgrade.txt index 38395c33be4..4773d4a793d 100644 --- a/analytics/upgrade.txt +++ b/analytics/upgrade.txt @@ -30,6 +30,10 @@ information provided here is intended especially for developers. * Predictions flagged as "Not useful" in models whose targets use analysers that provide multiple samples per analysable (e.g. students at risk or no teaching) have been updated to "Incorrectly flagged". * \core_analytics\predictor::delete_output_dir has a new 2nd parameter, $uniquemodelid. +* Analyser's get_analysables_iterator and get_iterator_sql have a new $contexts parameter to limit the returned analysables to + the ones that depend on the provided contexts. +* Analysers can implement a context_restriction_support() method to restrict models to a subset of the + contents in the site. Only CONTEXT_COURSE and CONTEXT_COURSECAT are supported. === 3.7 === diff --git a/lang/en/analytics.php b/lang/en/analytics.php index 1c72cb1b07a..c32225abae5 100644 --- a/lang/en/analytics.php +++ b/lang/en/analytics.php @@ -46,8 +46,10 @@ $string['errorimportmissingcomponents'] = 'The provided model requires the follo $string['errorimportversionmismatches'] = 'The version of the following components differs from the version installed on this site: {$a}. You can use the option \'Ignore version mismatches\' to ignore these differences.'; $string['errorimportmissingclasses'] = 'The following analytics components are not available on this site: {$a->missingclasses}.'; $string['errorinvalidindicator'] = 'Invalid {$a} indicator'; +$string['errorinvalidcontexts'] = 'Some of the selected contexts can not be used in this target.'; $string['errorinvalidtarget'] = 'Invalid {$a} target'; $string['errorinvalidtimesplitting'] = 'Invalid analysis interval; please ensure you add the fully qualified class name.'; +$string['errornocontextrestrictions'] = 'The selected target does not support context restrictions'; $string['errornoexportconfig'] = 'There was a problem exporting the model configuration.'; $string['errornoexportconfigrequirements'] = 'Only non-static models with an analysis interval can be exported.'; $string['errornoindicators'] = 'This model does not have any indicators.'; diff --git a/lib/classes/analytics/analyser/users.php b/lib/classes/analytics/analyser/users.php index 40ba586e762..76bf3942f0a 100644 --- a/lib/classes/analytics/analyser/users.php +++ b/lib/classes/analytics/analyser/users.php @@ -39,14 +39,15 @@ class users extends \core_analytics\local\analyser\base { * The site users are the analysable elements returned by this analyser. * * @param string|null $action 'prediction', 'training' or null if no specific action needed. + * @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty. * @return \Iterator */ - public function get_analysables_iterator(?string $action = null) { + public function get_analysables_iterator(?string $action = null, array $contexts = []) { global $DB, $CFG; $siteadmins = explode(',', $CFG->siteadmins); - list($sql, $params) = $this->get_iterator_sql('user', CONTEXT_USER, $action, 'u'); + list($sql, $params) = $this->get_iterator_sql('user', CONTEXT_USER, $action, 'u', $contexts); $sql .= " AND u.deleted = :deleted AND u.confirmed = :confirmed AND u.suspended = :suspended"; $params = $params + ['deleted' => 0, 'confirmed' => 1, 'suspended' => 0]; diff --git a/lib/db/install.xml b/lib/db/install.xml index b762bfcf675..a2e970f3020 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -3844,6 +3844,7 @@ + diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index 5f27ae17f66..d4c526e4a63 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -3619,5 +3619,24 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2019101600.01); } + if ($oldversion < 2019101800.02) { + + // Get the table by its previous name. + $table = new xmldb_table('analytics_models'); + if ($dbman->table_exists($table)) { + + // Define field contextids to be added to analytics_models. + $field = new xmldb_field('contextids', XMLDB_TYPE_TEXT, null, null, null, null, null, 'version'); + + // Conditionally launch add field contextids. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2019101800.02); + } + return true; } diff --git a/lib/tests/analysers_test.php b/lib/tests/analysers_test.php index f52ab0f9636..2c6160326aa 100644 --- a/lib/tests/analysers_test.php +++ b/lib/tests/analysers_test.php @@ -48,16 +48,16 @@ class core_analytics_analysers_testcase extends advanced_testcase { public function test_courses_analyser() { $this->resetAfterTest(true); - $course = $this->getDataGenerator()->create_course(); - $coursecontext = \context_course::instance($course->id); + $course1 = $this->getDataGenerator()->create_course(); + $coursecontext = \context_course::instance($course1->id); $target = new test_target_shortname(); $analyser = new \core\analytics\analyser\courses(1, $target, [], [], []); - $analysable = new \core_analytics\course($course); + $analysable = new \core_analytics\course($course1); - $this->assertInstanceOf('\core_analytics\course', $analyser->get_sample_analysable($course->id)); + $this->assertInstanceOf('\core_analytics\course', $analyser->get_sample_analysable($course1->id)); - $this->assertInstanceOf('\context_course', $analyser->sample_access_context($course->id)); + $this->assertInstanceOf('\context_course', $analyser->sample_access_context($course1->id)); // Just 1 sample per course. $class = new ReflectionClass('\core\analytics\analyser\courses'); @@ -66,8 +66,8 @@ class core_analytics_analysers_testcase extends advanced_testcase { list($sampleids, $samplesdata) = $method->invoke($analyser, $analysable); $this->assertCount(1, $sampleids); $sampleid = reset($sampleids); - $this->assertEquals($course->id, $sampleid); - $this->assertEquals($course->fullname, $samplesdata[$sampleid]['course']->fullname); + $this->assertEquals($course1->id, $sampleid); + $this->assertEquals($course1->fullname, $samplesdata[$sampleid]['course']->fullname); $this->assertEquals($coursecontext, $samplesdata[$sampleid]['context']); // To compare it later. @@ -75,6 +75,16 @@ class core_analytics_analysers_testcase extends advanced_testcase { list($sampleids, $samplesdata) = $analyser->get_samples(array($sampleid)); $this->assertEquals($prevsampledata['context'], $samplesdata[$sampleid]['context']); $this->assertEquals($prevsampledata['course']->shortname, $samplesdata[$sampleid]['course']->shortname); + + // Context restriction. + $category1 = $this->getDataGenerator()->create_category(); + $category1context = \context_coursecat::instance($category1->id); + $category2 = $this->getDataGenerator()->create_category(); + $category2context = \context_coursecat::instance($category2->id); + $course2 = $this->getDataGenerator()->create_course(['category' => $category1->id]); + $course3 = $this->getDataGenerator()->create_course(['category' => $category2->id]); + $this->assertCount(2, $analyser->get_analysables_iterator(false, [$category1context, $category2context])); + } /** @@ -130,24 +140,24 @@ class core_analytics_analysers_testcase extends advanced_testcase { $this->resetAfterTest(true); - $course = $this->getDataGenerator()->create_course(); - $coursecontext = \context_course::instance($course->id); + $course1 = $this->getDataGenerator()->create_course(); + $course1context = \context_course::instance($course1->id); $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); $user3 = $this->getDataGenerator()->create_user(); // Checking that suspended users are also included. - $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student'); - $this->getDataGenerator()->enrol_user($user2->id, $course->id, 'student', 'manual', 0, 0, ENROL_USER_SUSPENDED); - $this->getDataGenerator()->enrol_user($user3->id, $course->id, 'editingteacher'); - $enrol = $DB->get_record('enrol', array('courseid' => $course->id, 'enrol' => 'manual')); + $this->getDataGenerator()->enrol_user($user1->id, $course1->id, 'student'); + $this->getDataGenerator()->enrol_user($user2->id, $course1->id, 'student', 'manual', 0, 0, ENROL_USER_SUSPENDED); + $this->getDataGenerator()->enrol_user($user3->id, $course1->id, 'editingteacher'); + $enrol = $DB->get_record('enrol', array('courseid' => $course1->id, 'enrol' => 'manual')); $ue1 = $DB->get_record('user_enrolments', array('userid' => $user1->id, 'enrolid' => $enrol->id)); $ue2 = $DB->get_record('user_enrolments', array('userid' => $user2->id, 'enrolid' => $enrol->id)); $target = new test_target_shortname(); $analyser = new \core\analytics\analyser\student_enrolments(1, $target, [], [], []); - $analysable = new \core_analytics\course($course); + $analysable = new \core_analytics\course($course1); $this->assertInstanceOf('\core_analytics\course', $analyser->get_sample_analysable($ue1->id)); $this->assertInstanceOf('\context_course', $analyser->sample_access_context($ue1->id)); @@ -165,8 +175,8 @@ class core_analytics_analysers_testcase extends advanced_testcase { // Shouldn't matter which one we select. $sampleid = $ue1->id; $this->assertEquals($ue1, $samplesdata[$sampleid]['user_enrolments']); - $this->assertEquals($course->fullname, $samplesdata[$sampleid]['course']->fullname); - $this->assertEquals($coursecontext, $samplesdata[$sampleid]['context']); + $this->assertEquals($course1->fullname, $samplesdata[$sampleid]['course']->fullname); + $this->assertEquals($course1context, $samplesdata[$sampleid]['context']); $this->assertEquals($user1->firstname, $samplesdata[$sampleid]['user']->firstname); // To compare it later. @@ -176,6 +186,15 @@ class core_analytics_analysers_testcase extends advanced_testcase { $this->assertEquals($prevsampledata['context'], $samplesdata[$sampleid]['context']); $this->assertEquals($prevsampledata['course']->shortname, $samplesdata[$sampleid]['course']->shortname); $this->assertEquals($prevsampledata['user']->firstname, $samplesdata[$sampleid]['user']->firstname); + + // Context restriction. + $category1 = $this->getDataGenerator()->create_category(); + $category1context = \context_coursecat::instance($category1->id); + $category2 = $this->getDataGenerator()->create_category(); + $category2context = \context_coursecat::instance($category2->id); + $course2 = $this->getDataGenerator()->create_course(['category' => $category1->id]); + $course3 = $this->getDataGenerator()->create_course(['category' => $category2->id]); + $this->assertCount(2, $analyser->get_analysables_iterator(false, [$category1context, $category2context])); } /** diff --git a/version.php b/version.php index 297ec2862dc..6ecdd39c873 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2019101800.01; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2019101800.02; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes.