MDL-61667 analytics: Add methods to read and validate model declarations
[moodle.git] / analytics / classes / manager.php
CommitLineData
369389c9
DM
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/>.
16
17/**
b94dbb55 18 * Analytics basic actions manager.
369389c9
DM
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 */
24
25namespace core_analytics;
26
27defined('MOODLE_INTERNAL') || die();
28
29/**
b94dbb55 30 * Analytics basic actions manager.
369389c9
DM
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 */
36class manager {
37
ed12ba6b
DM
38 /**
39 * Default mlbackend
40 */
41 const DEFAULT_MLBACKEND = '\mlbackend_php\processor';
42
f0584045
DM
43 /**
44 * Name of the file where components declare their models.
45 */
46 const ANALYTICS_FILENAME = 'db/analytics.php';
47
369389c9
DM
48 /**
49 * @var \core_analytics\predictor[]
50 */
51 protected static $predictionprocessors = null;
52
e4453adc
DM
53 /**
54 * @var \core_analytics\local\target\base[]
55 */
56 protected static $alltargets = null;
57
369389c9
DM
58 /**
59 * @var \core_analytics\local\indicator\base[]
60 */
61 protected static $allindicators = null;
62
63 /**
64 * @var \core_analytics\local\time_splitting\base[]
65 */
66 protected static $alltimesplittings = null;
67
1611308b
DM
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 }
77
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 }
88
584ffa4f
DM
89 /**
90 * Returns all system models that match the provided filters.
91 *
92 * @param bool $enabled
93 * @param bool $trained
08015e18 94 * @param \context|false $predictioncontext
584ffa4f
DM
95 * @return \core_analytics\model[]
96 */
369389c9
DM
97 public static function get_all_models($enabled = false, $trained = false, $predictioncontext = false) {
98 global $DB;
99
1611308b
DM
100 $params = array();
101
88777c57 102 $sql = "SELECT am.* FROM {analytics_models} am";
1611308b 103
88777c57 104 if ($enabled || $trained || $predictioncontext) {
1611308b
DM
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 }
88777c57
DM
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 }
e10b29ed 118 $sql .= ' WHERE ' . implode(' AND ', $conditions);
369389c9 119 }
e10b29ed
DM
120 $sql .= ' ORDER BY am.enabled DESC, am.timemodified DESC';
121
1611308b 122 $modelobjs = $DB->get_records_sql($sql, $params);
369389c9
DM
123
124 $models = array();
125 foreach ($modelobjs as $modelobj) {
3a396286
DM
126 $model = new \core_analytics\model($modelobj);
127 if ($model->is_available()) {
128 $models[$modelobj->id] = $model;
129 }
369389c9
DM
130 }
131 return $models;
132 }
133
134 /**
ed12ba6b 135 * Returns the provided predictions processor class.
369389c9 136 *
ed12ba6b 137 * @param false|string $predictionclass Returns the system default processor if false
369389c9
DM
138 * @param bool $checkisready
139 * @return \core_analytics\predictor
140 */
141 public static function get_predictions_processor($predictionclass = false, $checkisready = true) {
142
143 // We want 0 or 1 so we can use it as an array key for caching.
144 $checkisready = intval($checkisready);
145
ed12ba6b 146 if (!$predictionclass) {
369389c9
DM
147 $predictionclass = get_config('analytics', 'predictionsprocessor');
148 }
149
150 if (empty($predictionclass)) {
151 // Use the default one if nothing set.
ed12ba6b 152 $predictionclass = self::default_mlbackend();
369389c9
DM
153 }
154
155 if (!class_exists($predictionclass)) {
156 throw new \coding_exception('Invalid predictions processor ' . $predictionclass . '.');
157 }
158
159 $interfaces = class_implements($predictionclass);
160 if (empty($interfaces['core_analytics\predictor'])) {
161 throw new \coding_exception($predictionclass . ' should implement \core_analytics\predictor.');
162 }
163
164 // Return it from the cached list.
165 if (!isset(self::$predictionprocessors[$checkisready][$predictionclass])) {
166
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 }
176
177 return self::$predictionprocessors[$checkisready][$predictionclass];
178 }
179
1611308b
DM
180 /**
181 * Return all system predictions processors.
182 *
08015e18 183 * @return \core_analytics\predictor[]
1611308b 184 */
369389c9
DM
185 public static function get_all_prediction_processors() {
186
187 $mlbackends = \core_component::get_plugin_list('mlbackend');
188
189 $predictionprocessors = array();
190 foreach ($mlbackends as $mlbackend => $unused) {
206d7aa9 191 $classfullpath = '\mlbackend_' . $mlbackend . '\processor';
369389c9
DM
192 $predictionprocessors[$classfullpath] = self::get_predictions_processor($classfullpath, false);
193 }
194 return $predictionprocessors;
195 }
196
ed12ba6b
DM
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 }
207
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 }
224
225 // Default predictions processor.
226 $defaultprocessorclass = get_config('analytics', 'predictionsprocessor');
227 $pluginclass = '\\' . $plugin . '\\processor';
228 if ($pluginclass === $defaultprocessorclass) {
229 return true;
230 }
231
232 return false;
233 }
234
369389c9
DM
235 /**
236 * Get all available time splitting methods.
237 *
1cc2b4ba 238 * @return \core_analytics\local\time_splitting\base[]
369389c9
DM
239 */
240 public static function get_all_time_splittings() {
241 if (self::$alltimesplittings !== null) {
242 return self::$alltimesplittings;
243 }
244
245 $classes = self::get_analytics_classes('time_splitting');
246
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 }
255
256 return self::$alltimesplittings;
257 }
258
259 /**
260 * Returns the enabled time splitting methods.
261 *
3576b66b
DM
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
369389c9
DM
265 * @return \core_analytics\local\time_splitting\base[]
266 */
267 public static function get_enabled_time_splitting_methods() {
3576b66b
DM
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 }
273
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() {
369389c9 280
3576b66b 281 if ($enabledtimesplittings = get_config('analytics', 'defaulttimesplittingsevaluation')) {
369389c9
DM
282 $enabledtimesplittings = array_flip(explode(',', $enabledtimesplittings));
283 }
284
285 $timesplittings = self::get_all_time_splittings();
286 foreach ($timesplittings as $key => $timesplitting) {
287
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 }
295
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 }
308
e4453adc
DM
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 }
318
319 $classes = self::get_analytics_classes('target');
320
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 }
328
329 return self::$alltargets;
330 }
369389c9
DM
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 }
340
341 $classes = self::get_analytics_classes('indicator');
342
343 self::$allindicators = [];
344 foreach ($classes as $fullclassname => $classpath) {
345 $instance = self::get_indicator($fullclassname);
346 if ($instance) {
b0c24929 347 self::$allindicators[$instance->get_id()] = $instance;
369389c9
DM
348 }
349 }
350
351 return self::$allindicators;
352 }
353
1611308b
DM
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 */
369389c9
DM
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 }
366
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 }
379
380 /**
381 * Returns whether a time splitting method is valid or not.
382 *
383 * @param string $fullclassname
1611308b 384 * @param string $baseclass
369389c9
DM
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 }
395
f67f35f3 396 /**
1611308b 397 * Returns the logstore used for analytics.
f67f35f3 398 *
2db6e981 399 * @return \core\log\sql_reader|false False if no log stores are enabled.
f67f35f3
DM
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');
85e50ba4
DM
404
405 if (!empty($analyticsstore) && !empty($readers[$analyticsstore])) {
f67f35f3 406 $logstore = $readers[$analyticsstore];
85e50ba4
DM
407 } else if (empty($analyticsstore) && !empty($readers)) {
408 // The first one, it is the same default than in settings.
409 $logstore = reset($readers);
2db6e981 410 } else if (!empty($readers)) {
f67f35f3
DM
411 $logstore = reset($readers);
412 debugging('The selected log store for analytics is not available anymore. Using "' .
413 $logstore->get_name() . '"', DEBUG_DEVELOPER);
414 }
415
2db6e981
DM
416 if (empty($logstore)) {
417 debugging('No system log stores available to use for analytics', DEBUG_DEVELOPER);
418 return false;
419 }
420
f67f35f3
DM
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 }
425
426 return $logstore;
427 }
428
0690a271
DM
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;
440
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');
444
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 }
a938e409 452 $calculations->close();
0690a271
DM
453 return $existingcalculations;
454 }
455
1611308b
DM
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) {
463
464 self::check_can_list_insights($context);
465
413f19bc 466 $models = self::get_all_models(true, true, $context);
1611308b
DM
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 }
475
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;
485
486 if (!$predictionobj = $DB->get_record('analytics_predictions', array('id' => $predictionid))) {
4a210b06
DM
487 throw new \moodle_exception('errorpredictionnotfound', 'analytics');
488 }
489
490 $context = \context::instance_by_id($predictionobj->contextid, IGNORE_MISSING);
491 if (!$context) {
492 throw new \moodle_exception('errorpredictioncontextnotavailable', 'analytics');
1611308b
DM
493 }
494
495 if ($requirelogin) {
496 list($context, $course, $cm) = get_context_info_array($predictionobj->contextid);
497 require_login($course, false, $cm);
1611308b
DM
498 }
499
413f19bc 500 self::check_can_list_insights($context);
1611308b
DM
501
502 $model = new \core_analytics\model($predictionobj->modelid);
503 $sampledata = $model->prediction_sample_data($predictionobj);
504 $prediction = new \core_analytics\prediction($predictionobj, $sampledata);
505
506 return array($model, $prediction, $context);
507 }
508
e709e544
DM
509 /**
510 * Adds the models included with moodle core to the system.
511 *
512 * @return void
513 */
514 public static function add_builtin_models() {
515
690ad875 516 $target = self::get_target('\core\analytics\target\course_dropout');
e709e544
DM
517
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',
0328f12b
DM
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',
6e98bca0 567 '\core\analytics\indicator\any_write_action_in_course',
0328f12b 568 '\core\analytics\indicator\read_actions',
e709e544
DM
569 );
570 $indicators = array();
571 foreach ($coiindicators as $coiindicator) {
690ad875 572 $indicator = self::get_indicator($coiindicator);
e709e544
DM
573 $indicators[$indicator->get_id()] = $indicator;
574 }
575 if (!\core_analytics\model::exists($target, $indicators)) {
576 $model = \core_analytics\model::create($target, $indicators);
577 }
578
579 // No teaching model.
690ad875 580 $target = self::get_target('\core\analytics\target\no_teaching');
e709e544 581 $timesplittingmethod = '\core\analytics\time_splitting\single_range';
690ad875 582 $noteacher = self::get_indicator('\core_course\analytics\indicator\no_teacher');
0927604f
DM
583 $nostudent = self::get_indicator('\core_course\analytics\indicator\no_student');
584 $indicators = array($noteacher->get_id() => $noteacher, $nostudent->get_id() => $nostudent);
e709e544
DM
585 if (!\core_analytics\model::exists($target, $indicators)) {
586 \core_analytics\model::create($target, $indicators, $timesplittingmethod);
587 }
588 }
589
f9222c49
DM
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;
595
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);
601
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);
606
607 $contexts = $apcontexts + $indcalccontexts;
608 if ($contexts) {
609 list($sql, $params) = $DB->get_in_or_equal(array_keys($contexts));
e08c74f5 610 $DB->execute("DELETE FROM {analytics_prediction_actions} WHERE predictionid IN
f9222c49
DM
611 (SELECT ap.id FROM {analytics_predictions} ap WHERE ap.contextid $sql)", $params);
612
613 $DB->delete_records_select('analytics_predictions', "contextid $sql", $params);
614 $DB->delete_records_select('analytics_indicator_calc', "contextid $sql", $params);
615 }
616
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 }
625
626 $analysableids = array_map(function($analysable) {
627 return $analysable->get_id();
628 }, $analysables);
629
630 list($notinsql, $params) = $DB->get_in_or_equal($analysableids, SQL_PARAMS_NAMED, 'param', false);
631 $params['modelid'] = $model->get_id();
632
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 }
637
ed12ba6b
DM
638 /**
639 * Default system backend.
640 *
641 * @return string
642 */
643 public static function default_mlbackend() {
644 return self::DEFAULT_MLBACKEND;
645 }
646
369389c9
DM
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) {
654
655 // Just in case...
1611308b 656 $element = clean_param($element, PARAM_ALPHANUMEXT);
369389c9 657
206d7aa9
DM
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);
660
661 // Plugins.
369389c9
DM
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 }
1cc2b4ba 668
206d7aa9 669 // Core subsystems.
1cc2b4ba
DM
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 }
674
369389c9
DM
675 return $classes;
676 }
f0584045
DM
677
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 {
686
687 $dir = \core_component::get_component_directory($componentname);
688
689 if (!$dir) {
690 // This is either an invalid component, or a core subsystem without its own root directory.
691 return [];
692 }
693
694 $file = $dir . '/' . self::ANALYTICS_FILENAME;
695
696 if (!is_readable($file)) {
697 return [];
698 }
699
700 $models = null;
701 include($file);
702
703 if (!isset($models) || !is_array($models) || empty($models)) {
704 return [];
705 }
706
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 }
714
715 static::validate_models_declaration($models);
716
717 return $models;
718 }
719
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) {
741
742 foreach ($models as $model) {
743 if (!isset($model['target'])) {
744 throw new \coding_exception('Missing target declaration');
745 }
746
747 if (!static::is_valid($model['target'], '\core_analytics\local\target\base')) {
748 throw new \coding_exception('Invalid target classname', $model['target']);
749 }
750
751 if (empty($model['indicators']) || !is_array($model['indicators'])) {
752 throw new \coding_exception('Missing indicators declaration');
753 }
754
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 }
760
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 }
769
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 }
369389c9 775}