7b41ee069b0c00227ee0411f29534bbd7ce4b21c
[moodle.git] / analytics / classes / manager.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Analytics basic actions manager.
19  *
20  * @package   core_analytics
21  * @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
22  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace core_analytics;
27 defined('MOODLE_INTERNAL') || die();
29 /**
30  * Analytics basic actions manager.
31  *
32  * @package   core_analytics
33  * @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
34  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35  */
36 class manager {
38     /**
39      * Default mlbackend
40      */
41     const DEFAULT_MLBACKEND = '\mlbackend_php\processor';
43     /**
44      * Name of the file where components declare their models.
45      */
46     const ANALYTICS_FILENAME = 'db/analytics.php';
48     /**
49      * @var \core_analytics\predictor[]
50      */
51     protected static $predictionprocessors = null;
53     /**
54      * @var \core_analytics\local\target\base[]
55      */
56     protected static $alltargets = null;
58     /**
59      * @var \core_analytics\local\indicator\base[]
60      */
61     protected static $allindicators = null;
63     /**
64      * @var \core_analytics\local\time_splitting\base[]
65      */
66     protected static $alltimesplittings = null;
68     /**
69      * Checks that the user can manage models
70      *
71      * @throws \required_capability_exception
72      * @return void
73      */
74     public static function check_can_manage_models() {
75         require_capability('moodle/analytics:managemodels', \context_system::instance());
76     }
78     /**
79      * Checks that the user can list that context insights
80      *
81      * @throws \required_capability_exception
82      * @param \context $context
83      * @return void
84      */
85     public static function check_can_list_insights(\context $context) {
86         require_capability('moodle/analytics:listinsights', $context);
87     }
89     /**
90      * Returns all system models that match the provided filters.
91      *
92      * @param bool $enabled
93      * @param bool $trained
94      * @param \context|false $predictioncontext
95      * @return \core_analytics\model[]
96      */
97     public static function get_all_models($enabled = false, $trained = false, $predictioncontext = false) {
98         global $DB;
100         $params = array();
102         $sql = "SELECT am.* FROM {analytics_models} am";
104         if ($enabled || $trained || $predictioncontext) {
105             $conditions = [];
106             if ($enabled) {
107                 $conditions[] = 'am.enabled = :enabled';
108                 $params['enabled'] = 1;
109             }
110             if ($trained) {
111                 $conditions[] = 'am.trained = :trained';
112                 $params['trained'] = 1;
113             }
114             if ($predictioncontext) {
115                 $conditions[] = "EXISTS (SELECT 'x' FROM {analytics_predictions} ap WHERE ap.modelid = am.id AND ap.contextid = :contextid)";
116                 $params['contextid'] = $predictioncontext->id;
117             }
118             $sql .= ' WHERE ' . implode(' AND ', $conditions);
119         }
120         $sql .= ' ORDER BY am.enabled DESC, am.timemodified DESC';
122         $modelobjs = $DB->get_records_sql($sql, $params);
124         $models = array();
125         foreach ($modelobjs as $modelobj) {
126             $model = new \core_analytics\model($modelobj);
127             if ($model->is_available()) {
128                 $models[$modelobj->id] = $model;
129             }
130         }
131         return $models;
132     }
134     /**
135      * Returns the provided predictions processor class.
136      *
137      * @param false|string $predictionclass Returns the system default processor if false
138      * @param bool $checkisready
139      * @return \core_analytics\predictor
140      */
141     public static function get_predictions_processor($predictionclass = false, $checkisready = true) {
143         // We want 0 or 1 so we can use it as an array key for caching.
144         $checkisready = intval($checkisready);
146         if (!$predictionclass) {
147             $predictionclass = get_config('analytics', 'predictionsprocessor');
148         }
150         if (empty($predictionclass)) {
151             // Use the default one if nothing set.
152             $predictionclass = self::default_mlbackend();
153         }
155         if (!class_exists($predictionclass)) {
156             throw new \coding_exception('Invalid predictions processor ' . $predictionclass . '.');
157         }
159         $interfaces = class_implements($predictionclass);
160         if (empty($interfaces['core_analytics\predictor'])) {
161             throw new \coding_exception($predictionclass . ' should implement \core_analytics\predictor.');
162         }
164         // Return it from the cached list.
165         if (!isset(self::$predictionprocessors[$checkisready][$predictionclass])) {
167             $instance = new $predictionclass();
168             if ($checkisready) {
169                 $isready = $instance->is_ready();
170                 if ($isready !== true) {
171                     throw new \moodle_exception('errorprocessornotready', 'analytics', '', $isready);
172                 }
173             }
174             self::$predictionprocessors[$checkisready][$predictionclass] = $instance;
175         }
177         return self::$predictionprocessors[$checkisready][$predictionclass];
178     }
180     /**
181      * Return all system predictions processors.
182      *
183      * @return \core_analytics\predictor[]
184      */
185     public static function get_all_prediction_processors() {
187         $mlbackends = \core_component::get_plugin_list('mlbackend');
189         $predictionprocessors = array();
190         foreach ($mlbackends as $mlbackend => $unused) {
191             $classfullpath = '\mlbackend_' . $mlbackend . '\processor';
192             $predictionprocessors[$classfullpath] = self::get_predictions_processor($classfullpath, false);
193         }
194         return $predictionprocessors;
195     }
197     /**
198      * Returns the name of the provided predictions processor.
199      *
200      * @param \core_analytics\predictor $predictionsprocessor
201      * @return string
202      */
203     public static function get_predictions_processor_name(\core_analytics\predictor $predictionsprocessor) {
204             $component = substr(get_class($predictionsprocessor), 0, strpos(get_class($predictionsprocessor), '\\', 1));
205         return get_string('pluginname', $component);
206     }
208     /**
209      * Whether the provided plugin is used by any model.
210      *
211      * @param string $plugin
212      * @return bool
213      */
214     public static function is_mlbackend_used($plugin) {
215         $models = self::get_all_models();
216         foreach ($models as $model) {
217             $processor = $model->get_predictions_processor();
218             $noprefixnamespace = ltrim(get_class($processor), '\\');
219             $processorplugin = substr($noprefixnamespace, 0, strpos($noprefixnamespace, '\\'));
220             if ($processorplugin == $plugin) {
221                 return true;
222             }
223         }
225         // Default predictions processor.
226         $defaultprocessorclass = get_config('analytics', 'predictionsprocessor');
227         $pluginclass = '\\' . $plugin . '\\processor';
228         if ($pluginclass === $defaultprocessorclass) {
229             return true;
230         }
232         return false;
233     }
235     /**
236      * Get all available time splitting methods.
237      *
238      * @return \core_analytics\local\time_splitting\base[]
239      */
240     public static function get_all_time_splittings() {
241         if (self::$alltimesplittings !== null) {
242             return self::$alltimesplittings;
243         }
245         $classes = self::get_analytics_classes('time_splitting');
247         self::$alltimesplittings = [];
248         foreach ($classes as $fullclassname => $classpath) {
249             $instance = self::get_time_splitting($fullclassname);
250             // We need to check that it is a valid time splitting method, it may be an abstract class.
251             if ($instance) {
252                 self::$alltimesplittings[$instance->get_id()] = $instance;
253             }
254         }
256         return self::$alltimesplittings;
257     }
259     /**
260      * Returns the enabled time splitting methods.
261      *
262      * @deprecated since Moodle 3.7
263      * @todo MDL-65086 This will be deleted in Moodle 4.1
264      * @see \core_analytics\manager::get_time_splitting_methods_for_evaluation
265      * @return \core_analytics\local\time_splitting\base[]
266      */
267     public static function get_enabled_time_splitting_methods() {
268         debugging('This function has been deprecated. You can use self::get_time_splitting_methods_for_evaluation if ' .
269             'you want to get the default time splitting methods for evaluation, or you can use self::get_all_time_splittings if ' .
270             'you want to get all the time splitting methods available on this site.');
271         return self::get_time_splitting_methods_for_evaluation();
272     }
274     /**
275      * Returns the default time splitting methods for model evaluation.
276      *
277      * @return \core_analytics\local\time_splitting\base[]
278      */
279     public static function get_time_splitting_methods_for_evaluation() {
281         if ($enabledtimesplittings = get_config('analytics', 'defaulttimesplittingsevaluation')) {
282             $enabledtimesplittings = array_flip(explode(',', $enabledtimesplittings));
283         }
285         $timesplittings = self::get_all_time_splittings();
286         foreach ($timesplittings as $key => $timesplitting) {
288             // We remove the ones that are not enabled. This also respects the default value (all methods enabled).
289             if (!empty($enabledtimesplittings) && !isset($enabledtimesplittings[$key])) {
290                 unset($timesplittings[$key]);
291             }
292         }
293         return $timesplittings;
294     }
296     /**
297      * Returns a time splitting method by its classname.
298      *
299      * @param string $fullclassname
300      * @return \core_analytics\local\time_splitting\base|false False if it is not valid.
301      */
302     public static function get_time_splitting($fullclassname) {
303         if (!self::is_valid($fullclassname, '\core_analytics\local\time_splitting\base')) {
304             return false;
305         }
306         return new $fullclassname();
307     }
309     /**
310      * Return all targets in the system.
311      *
312      * @return \core_analytics\local\target\base[]
313      */
314     public static function get_all_targets() : array {
315         if (self::$alltargets !== null) {
316             return self::$alltargets;
317         }
319         $classes = self::get_analytics_classes('target');
321         self::$alltargets = [];
322         foreach ($classes as $fullclassname => $classpath) {
323             $instance = self::get_target($fullclassname);
324             if ($instance) {
325                 self::$alltargets[$instance->get_id()] = $instance;
326             }
327         }
329         return self::$alltargets;
330     }
331     /**
332      * Return all system indicators.
333      *
334      * @return \core_analytics\local\indicator\base[]
335      */
336     public static function get_all_indicators() {
337         if (self::$allindicators !== null) {
338             return self::$allindicators;
339         }
341         $classes = self::get_analytics_classes('indicator');
343         self::$allindicators = [];
344         foreach ($classes as $fullclassname => $classpath) {
345             $instance = self::get_indicator($fullclassname);
346             if ($instance) {
347                 self::$allindicators[$instance->get_id()] = $instance;
348             }
349         }
351         return self::$allindicators;
352     }
354     /**
355      * Returns the specified target
356      *
357      * @param mixed $fullclassname
358      * @return \core_analytics\local\target\base|false False if it is not valid
359      */
360     public static function get_target($fullclassname) {
361         if (!self::is_valid($fullclassname, 'core_analytics\local\target\base')) {
362             return false;
363         }
364         return new $fullclassname();
365     }
367     /**
368      * Returns an instance of the provided indicator.
369      *
370      * @param string $fullclassname
371      * @return \core_analytics\local\indicator\base|false False if it is not valid.
372      */
373     public static function get_indicator($fullclassname) {
374         if (!self::is_valid($fullclassname, 'core_analytics\local\indicator\base')) {
375             return false;
376         }
377         return new $fullclassname();
378     }
380     /**
381      * Returns whether a time splitting method is valid or not.
382      *
383      * @param string $fullclassname
384      * @param string $baseclass
385      * @return bool
386      */
387     public static function is_valid($fullclassname, $baseclass) {
388         if (is_subclass_of($fullclassname, $baseclass)) {
389             if ((new \ReflectionClass($fullclassname))->isInstantiable()) {
390                 return true;
391             }
392         }
393         return false;
394     }
396     /**
397      * Returns the logstore used for analytics.
398      *
399      * @return \core\log\sql_reader|false False if no log stores are enabled.
400      */
401     public static function get_analytics_logstore() {
402         $readers = get_log_manager()->get_readers('core\log\sql_reader');
403         $analyticsstore = get_config('analytics', 'logstore');
405         if (!empty($analyticsstore) && !empty($readers[$analyticsstore])) {
406             $logstore = $readers[$analyticsstore];
407         } else if (empty($analyticsstore) && !empty($readers)) {
408             // The first one, it is the same default than in settings.
409             $logstore = reset($readers);
410         } else if (!empty($readers)) {
411             $logstore = reset($readers);
412             debugging('The selected log store for analytics is not available anymore. Using "' .
413                 $logstore->get_name() . '"', DEBUG_DEVELOPER);
414         }
416         if (empty($logstore)) {
417             debugging('No system log stores available to use for analytics', DEBUG_DEVELOPER);
418             return false;
419         }
421         if (!$logstore->is_logging()) {
422             debugging('The selected log store for analytics "' . $logstore->get_name() .
423                 '" is not logging activity logs', DEBUG_DEVELOPER);
424         }
426         return $logstore;
427     }
429     /**
430      * Returns this analysable calculations during the provided period.
431      *
432      * @param \core_analytics\analysable $analysable
433      * @param int $starttime
434      * @param int $endtime
435      * @param string $samplesorigin The samples origin as sampleid is not unique across models.
436      * @return array
437      */
438     public static function get_indicator_calculations($analysable, $starttime, $endtime, $samplesorigin) {
439         global $DB;
441         $params = array('starttime' => $starttime, 'endtime' => $endtime, 'contextid' => $analysable->get_context()->id,
442             'sampleorigin' => $samplesorigin);
443         $calculations = $DB->get_recordset('analytics_indicator_calc', $params, '', 'indicator, sampleid, value');
445         $existingcalculations = array();
446         foreach ($calculations as $calculation) {
447             if (empty($existingcalculations[$calculation->indicator])) {
448                 $existingcalculations[$calculation->indicator] = array();
449             }
450             $existingcalculations[$calculation->indicator][$calculation->sampleid] = $calculation->value;
451         }
452         $calculations->close();
453         return $existingcalculations;
454     }
456     /**
457      * Returns the models with insights at the provided context.
458      *
459      * @param \context $context
460      * @return \core_analytics\model[]
461      */
462     public static function get_models_with_insights(\context $context) {
464         self::check_can_list_insights($context);
466         $models = self::get_all_models(true, true, $context);
467         foreach ($models as $key => $model) {
468             // Check that it not only have predictions but also generates insights from them.
469             if (!$model->uses_insights()) {
470                 unset($models[$key]);
471             }
472         }
473         return $models;
474     }
476     /**
477      * Returns a prediction
478      *
479      * @param int $predictionid
480      * @param bool $requirelogin
481      * @return array array($model, $prediction, $context)
482      */
483     public static function get_prediction($predictionid, $requirelogin = false) {
484         global $DB;
486         if (!$predictionobj = $DB->get_record('analytics_predictions', array('id' => $predictionid))) {
487             throw new \moodle_exception('errorpredictionnotfound', 'analytics');
488         }
490         $context = \context::instance_by_id($predictionobj->contextid, IGNORE_MISSING);
491         if (!$context) {
492             throw new \moodle_exception('errorpredictioncontextnotavailable', 'analytics');
493         }
495         if ($requirelogin) {
496             list($context, $course, $cm) = get_context_info_array($predictionobj->contextid);
497             require_login($course, false, $cm);
498         }
500         self::check_can_list_insights($context);
502         $model = new \core_analytics\model($predictionobj->modelid);
503         $sampledata = $model->prediction_sample_data($predictionobj);
504         $prediction = new \core_analytics\prediction($predictionobj, $sampledata);
506         return array($model, $prediction, $context);
507     }
509     /**
510      * Adds the models included with moodle core to the system.
511      *
512      * @return void
513      */
514     public static function add_builtin_models() {
516         $target = self::get_target('\core\analytics\target\course_dropout');
518         // Community of inquiry indicators.
519         $coiindicators = array(
520             '\mod_assign\analytics\indicator\cognitive_depth',
521             '\mod_assign\analytics\indicator\social_breadth',
522             '\mod_book\analytics\indicator\cognitive_depth',
523             '\mod_book\analytics\indicator\social_breadth',
524             '\mod_chat\analytics\indicator\cognitive_depth',
525             '\mod_chat\analytics\indicator\social_breadth',
526             '\mod_choice\analytics\indicator\cognitive_depth',
527             '\mod_choice\analytics\indicator\social_breadth',
528             '\mod_data\analytics\indicator\cognitive_depth',
529             '\mod_data\analytics\indicator\social_breadth',
530             '\mod_feedback\analytics\indicator\cognitive_depth',
531             '\mod_feedback\analytics\indicator\social_breadth',
532             '\mod_folder\analytics\indicator\cognitive_depth',
533             '\mod_folder\analytics\indicator\social_breadth',
534             '\mod_forum\analytics\indicator\cognitive_depth',
535             '\mod_forum\analytics\indicator\social_breadth',
536             '\mod_glossary\analytics\indicator\cognitive_depth',
537             '\mod_glossary\analytics\indicator\social_breadth',
538             '\mod_imscp\analytics\indicator\cognitive_depth',
539             '\mod_imscp\analytics\indicator\social_breadth',
540             '\mod_label\analytics\indicator\cognitive_depth',
541             '\mod_label\analytics\indicator\social_breadth',
542             '\mod_lesson\analytics\indicator\cognitive_depth',
543             '\mod_lesson\analytics\indicator\social_breadth',
544             '\mod_lti\analytics\indicator\cognitive_depth',
545             '\mod_lti\analytics\indicator\social_breadth',
546             '\mod_page\analytics\indicator\cognitive_depth',
547             '\mod_page\analytics\indicator\social_breadth',
548             '\mod_quiz\analytics\indicator\cognitive_depth',
549             '\mod_quiz\analytics\indicator\social_breadth',
550             '\mod_resource\analytics\indicator\cognitive_depth',
551             '\mod_resource\analytics\indicator\social_breadth',
552             '\mod_scorm\analytics\indicator\cognitive_depth',
553             '\mod_scorm\analytics\indicator\social_breadth',
554             '\mod_survey\analytics\indicator\cognitive_depth',
555             '\mod_survey\analytics\indicator\social_breadth',
556             '\mod_url\analytics\indicator\cognitive_depth',
557             '\mod_url\analytics\indicator\social_breadth',
558             '\mod_wiki\analytics\indicator\cognitive_depth',
559             '\mod_wiki\analytics\indicator\social_breadth',
560             '\mod_workshop\analytics\indicator\cognitive_depth',
561             '\mod_workshop\analytics\indicator\social_breadth',
562             '\core_course\analytics\indicator\completion_enabled',
563             '\core_course\analytics\indicator\potential_cognitive_depth',
564             '\core_course\analytics\indicator\potential_social_breadth',
565             '\core\analytics\indicator\any_access_after_end',
566             '\core\analytics\indicator\any_access_before_start',
567             '\core\analytics\indicator\any_write_action_in_course',
568             '\core\analytics\indicator\read_actions',
569         );
570         $indicators = array();
571         foreach ($coiindicators as $coiindicator) {
572             $indicator = self::get_indicator($coiindicator);
573             $indicators[$indicator->get_id()] = $indicator;
574         }
575         if (!\core_analytics\model::exists($target, $indicators)) {
576             $model = \core_analytics\model::create($target, $indicators);
577         }
579         // No teaching model.
580         $target = self::get_target('\core\analytics\target\no_teaching');
581         $timesplittingmethod = '\core\analytics\time_splitting\single_range';
582         $noteacher = self::get_indicator('\core_course\analytics\indicator\no_teacher');
583         $nostudent = self::get_indicator('\core_course\analytics\indicator\no_student');
584         $indicators = array($noteacher->get_id() => $noteacher, $nostudent->get_id() => $nostudent);
585         if (!\core_analytics\model::exists($target, $indicators)) {
586             \core_analytics\model::create($target, $indicators, $timesplittingmethod);
587         }
588     }
590     /**
591      * Cleans up analytics db tables that do not directly depend on analysables that may have been deleted.
592      */
593     public static function cleanup() {
594         global $DB;
596         // Clean up stuff that depends on contexts that do not exist anymore.
597         $sql = "SELECT DISTINCT ap.contextid FROM {analytics_predictions} ap
598                   LEFT JOIN {context} ctx ON ap.contextid = ctx.id
599                  WHERE ctx.id IS NULL";
600         $apcontexts = $DB->get_records_sql($sql);
602         $sql = "SELECT DISTINCT aic.contextid FROM {analytics_indicator_calc} aic
603                   LEFT JOIN {context} ctx ON aic.contextid = ctx.id
604                  WHERE ctx.id IS NULL";
605         $indcalccontexts = $DB->get_records_sql($sql);
607         $contexts = $apcontexts + $indcalccontexts;
608         if ($contexts) {
609             list($sql, $params) = $DB->get_in_or_equal(array_keys($contexts));
610             $DB->execute("DELETE FROM {analytics_prediction_actions} WHERE predictionid IN
611                 (SELECT ap.id FROM {analytics_predictions} ap WHERE ap.contextid $sql)", $params);
613             $DB->delete_records_select('analytics_predictions', "contextid $sql", $params);
614             $DB->delete_records_select('analytics_indicator_calc', "contextid $sql", $params);
615         }
617         // Clean up stuff that depends on analysable ids that do not exist anymore.
618         $models = self::get_all_models();
619         foreach ($models as $model) {
620             $analyser = $model->get_analyser(array('notimesplitting' => true));
621             $analysables = $analyser->get_analysables();
622             if (!$analysables) {
623                 continue;
624             }
626             $analysableids = array_map(function($analysable) {
627                 return $analysable->get_id();
628             }, $analysables);
630             list($notinsql, $params) = $DB->get_in_or_equal($analysableids, SQL_PARAMS_NAMED, 'param', false);
631             $params['modelid'] = $model->get_id();
633             $DB->delete_records_select('analytics_predict_samples', "modelid = :modelid AND analysableid $notinsql", $params);
634             $DB->delete_records_select('analytics_train_samples', "modelid = :modelid AND analysableid $notinsql", $params);
635         }
636     }
638     /**
639      * Default system backend.
640      *
641      * @return string
642      */
643     public static function default_mlbackend() {
644         return self::DEFAULT_MLBACKEND;
645     }
647     /**
648      * Returns the provided element classes in the site.
649      *
650      * @param string $element
651      * @return string[] Array keys are the FQCN and the values the class path.
652      */
653     private static function get_analytics_classes($element) {
655         // Just in case...
656         $element = clean_param($element, PARAM_ALPHANUMEXT);
658         // Core analytics classes (analytics subsystem should not contain uses of the analytics API).
659         $classes = \core_component::get_component_classes_in_namespace('core', 'analytics\\' . $element);
661         // Plugins.
662         foreach (\core_component::get_plugin_types() as $type => $unusedplugintypepath) {
663             foreach (\core_component::get_plugin_list($type) as $pluginname => $unusedpluginpath) {
664                 $frankenstyle = $type . '_' . $pluginname;
665                 $classes += \core_component::get_component_classes_in_namespace($frankenstyle, 'analytics\\' . $element);
666             }
667         }
669         // Core subsystems.
670         foreach (\core_component::get_core_subsystems() as $subsystemname => $unusedsubsystempath) {
671             $componentname = 'core_' . $subsystemname;
672             $classes += \core_component::get_component_classes_in_namespace($componentname, 'analytics\\' . $element);
673         }
675         return $classes;
676     }
678     /**
679      * Return the list of models declared by the given component.
680      *
681      * @param string $componentname The name of the component to load models for.
682      * @throws \coding_exception Exception thrown in case of invalid syntax.
683      * @return array The $models description array.
684      */
685     public static function load_default_models_for_component(string $componentname): array {
687         $dir = \core_component::get_component_directory($componentname);
689         if (!$dir) {
690             // This is either an invalid component, or a core subsystem without its own root directory.
691             return [];
692         }
694         $file = $dir . '/' . self::ANALYTICS_FILENAME;
696         if (!is_readable($file)) {
697             return [];
698         }
700         $models = null;
701         include($file);
703         if (!isset($models) || !is_array($models) || empty($models)) {
704             return [];
705         }
707         foreach ($models as &$model) {
708             if (!isset($model['enabled'])) {
709                 $model['enabled'] = false;
710             } else {
711                 $model['enabled'] = clean_param($model['enabled'], PARAM_BOOL);
712             }
713         }
715         static::validate_models_declaration($models);
717         return $models;
718     }
720     /**
721      * Validate the declaration of prediction models according the syntax expected in the component's db folder.
722      *
723      * The expected structure looks like this:
724      *
725      *  [
726      *      [
727      *          'target' => '\fully\qualified\name\of\the\target\class',
728      *          'indicators' => [
729      *              '\fully\qualified\name\of\the\first\indicator',
730      *              '\fully\qualified\name\of\the\second\indicator',
731      *          ],
732      *          'timesplitting' => '\optional\name\of\the\time_splitting\class',
733      *          'enabled' => true,
734      *      ],
735      *  ];
736      *
737      * @param array $models List of declared models.
738      * @throws \coding_exception Exception thrown in case of invalid syntax.
739      */
740     public static function validate_models_declaration(array $models) {
742         foreach ($models as $model) {
743             if (!isset($model['target'])) {
744                 throw new \coding_exception('Missing target declaration');
745             }
747             if (!static::is_valid($model['target'], '\core_analytics\local\target\base')) {
748                 throw new \coding_exception('Invalid target classname', $model['target']);
749             }
751             if (empty($model['indicators']) || !is_array($model['indicators'])) {
752                 throw new \coding_exception('Missing indicators declaration');
753             }
755             foreach ($model['indicators'] as $indicator) {
756                 if (!static::is_valid($indicator, '\core_analytics\local\indicator\base')) {
757                     throw new \coding_exception('Invalid indicator classname', $indicator);
758                 }
759             }
761             if (isset($model['timesplitting'])) {
762                 if (substr($model['timesplitting'], 0, 1) !== '\\') {
763                     throw new \coding_exception('Expecting fully qualified time splitting classname', $model['timesplitting']);
764                 }
765                 if (!static::is_valid($model['timesplitting'], '\core_analytics\local\time_splitting\base')) {
766                     throw new \coding_exception('Invalid time splitting classname', $model['timesplitting']);
767                 }
768             }
770             if (!empty($model['enabled']) && !isset($model['timesplitting'])) {
771                 throw new \coding_exception('Cannot enable a model without time splitting method specified');
772             }
773         }
774     }