$mform->addHelpButton('indicators', 'indicators', 'tool_analytics');
}
- // Contexts restriction.
- $contexts = array();
- if ($this->_customdata['contexts']) {
- $contexts = \tool_analytics\output\helper::contexts_to_options($this->_customdata['contexts']);
- }
- $options = array(
- 'multiple' => true
- );
- $mform->addElement('autocomplete', 'contexts', get_string('contexts', 'tool_analytics'), $contexts, $options);
- $mform->setType('contexts', PARAM_ALPHANUMEXT);
- $mform->addHelpButton('contexts', 'indicators', 'tool_analytics');
-
// Time-splitting methods.
if (!empty($this->_customdata['invalidcurrenttimesplitting'])) {
$mform->addElement('html', $OUTPUT->notification(
$mform->addElement('select', 'timesplitting', get_string('timesplittingmethod', 'analytics'), $timesplittings);
$mform->addHelpButton('timesplitting', 'timesplittingmethod', 'analytics');
+ // Contexts restriction.
+ if (!empty($this->_customdata['contexts'])) {
+
+ \core_collator::asort($this->_customdata['contexts']);
+ $options = ['multiple' => true, 'noselectionstring' => get_string('all')];
+ $mform->addElement('autocomplete', 'contexts', get_string('contexts', 'tool_analytics'), $this->_customdata['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(
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');
$singleselect = new \single_select($url, 'contextid', $contexts, $selected, $nothing);
return $singleselect->export_for_template($output);
}
-
- /**
- * Converts a list of contexts to an array of options that can be used in a autocomplete moodleform field.
- *
- * @param array $contexts Array of context ids.
- * @param bool $includeall Wether to include the "all" context option (the system context).
- * @param bool $shortentext Wether to shorten the context names.
- * @return array Associative array with context ids as keys and context names as values.
- */
- public static function contexts_to_options(array $contexts, ?bool $includeall = false, ?bool $shortentext = true): array {
-
- foreach ($contexts as $contextid) {
- $context = \context::instance_by_id($contextid);
-
- // Special name for system level predictions as showing "System is not visually nice".
- if ($contextid == SYSCONTEXTID) {
- $contextname = get_string('allpredictions', 'tool_analytics');
- } else {
- if ($shortentext) {
- $contextname = shorten_text($context->get_context_name(false, true), 40);
- } else {
- $contextname = $context->get_context_name(false, true);
- }
- }
- $contexts[$contextid] = $contextname;
- }
-
- if ($includeall) {
- $contexts[0] = get_string('all');
- }
-
- \core_collator::asort($contexts);
-
- return $contexts;
- }
}
$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;
'indicators' => \core_analytics\manager::get_all_indicators(),
'timesplittings' => \core_analytics\manager::get_all_time_splittings(),
'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors(),
+ 'contexts' => \core_analytics\manager::get_potential_context_restrictions(),
);
$mform = new \tool_analytics\output\form\edit_model(null, $customdata);
$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;
$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';
'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(),
+ 'contexts' => ($model->get_analyser())::potential_context_restrictions()
);
$mform = new \tool_analytics\output\form\edit_model(null, $customdata);
$predictionsprocessor = false;
}
- $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor);
+ $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor, $data->contexts);
redirect($returnurl);
}
$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();
/**
* 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();
} else {
$action = 'prediction';
}
- $analysables = $this->analyser->get_analysables_iterator($action);
+ $analysables = $this->analyser->get_analysables_iterator($action, $contexts);
$processedanalysables = $this->get_processed_analysables();
/**
* 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();
}
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.
+ *
+ * @return int[]
+ */
+ public static function potential_context_restrictions() {
+ return \core_analytics\manager::get_potential_context_restrictions(static::context_restriction_support());
+ }
+
/**
* Get the sql of a default implementation of the iterator.
*
return \core_analytics\course::instance($record, $context);
});
}
+
+ /**
+ * Can be limited to course categories or specific courses.
+ *
+ * @return array
+ */
+ public static function context_restriction_support(): array {
+ return [CONTEXT_COURSE, CONTEXT_COURSECAT];
+ }
}
$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 = [];
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.
+ * @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) {
+ 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";
+ $coursecats = $DB->get_recordset_sql($sql, ['ctxlevel' => CONTEXT_COURSECAT]);
+ 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";
+ $courses = $DB->get_recordset_sql($sql, ['ctxlevel' => CONTEXT_COURSE]);
+ foreach ($courses as $record) {
+ $contexts[$record->contextid] = get_string('course') . ': ' .
+ format_string($record->shortname, true, array('context' => $contextsystem));
+ }
+ $courses->close();
+ }
+
+ return $contexts;
+ }
+
}
*/
protected $indicators = null;
+ /**
+ * @var \context[]
+ */
+ protected $contexts = null;
+
/**
* Unique Model id created from site info and last model modification.
*
* @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();
$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) {
$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;
// 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)) {
// 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])) {
// 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])) {
} 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])) {
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.
*/
$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 frontpage and the misc. category get included.
+ $this->assertCount(2, \core_analytics\manager::get_potential_context_restrictions());
+
+ $this->getDataGenerator()->create_course();
+ $this->getDataGenerator()->create_category();
+ $this->assertCount(4, \core_analytics\manager::get_potential_context_restrictions());
+ $this->assertCount(4, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE, CONTEXT_COURSECAT]));
+
+ $this->assertCount(2, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE]));
+ $this->assertCount(2, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT]));
+ }
}
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
*
* \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 ===
$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.';