MDL-60944 analytics: Add base support for import / export
[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
369389c9
DM
43 /**
44 * @var \core_analytics\predictor[]
45 */
46 protected static $predictionprocessors = null;
47
48 /**
49 * @var \core_analytics\local\indicator\base[]
50 */
51 protected static $allindicators = null;
52
53 /**
54 * @var \core_analytics\local\time_splitting\base[]
55 */
56 protected static $alltimesplittings = null;
57
1611308b
DM
58 /**
59 * Checks that the user can manage models
60 *
61 * @throws \required_capability_exception
62 * @return void
63 */
64 public static function check_can_manage_models() {
65 require_capability('moodle/analytics:managemodels', \context_system::instance());
66 }
67
68 /**
69 * Checks that the user can list that context insights
70 *
71 * @throws \required_capability_exception
72 * @param \context $context
73 * @return void
74 */
75 public static function check_can_list_insights(\context $context) {
76 require_capability('moodle/analytics:listinsights', $context);
77 }
78
584ffa4f
DM
79 /**
80 * Returns all system models that match the provided filters.
81 *
82 * @param bool $enabled
83 * @param bool $trained
08015e18 84 * @param \context|false $predictioncontext
584ffa4f
DM
85 * @return \core_analytics\model[]
86 */
369389c9
DM
87 public static function get_all_models($enabled = false, $trained = false, $predictioncontext = false) {
88 global $DB;
89
1611308b
DM
90 $params = array();
91
88777c57 92 $sql = "SELECT am.* FROM {analytics_models} am";
1611308b 93
88777c57 94 if ($enabled || $trained || $predictioncontext) {
1611308b
DM
95 $conditions = [];
96 if ($enabled) {
97 $conditions[] = 'am.enabled = :enabled';
98 $params['enabled'] = 1;
99 }
100 if ($trained) {
101 $conditions[] = 'am.trained = :trained';
102 $params['trained'] = 1;
103 }
88777c57
DM
104 if ($predictioncontext) {
105 $conditions[] = "EXISTS (SELECT 'x' FROM {analytics_predictions} ap WHERE ap.modelid = am.id AND ap.contextid = :contextid)";
106 $params['contextid'] = $predictioncontext->id;
107 }
e10b29ed 108 $sql .= ' WHERE ' . implode(' AND ', $conditions);
369389c9 109 }
e10b29ed
DM
110 $sql .= ' ORDER BY am.enabled DESC, am.timemodified DESC';
111
1611308b 112 $modelobjs = $DB->get_records_sql($sql, $params);
369389c9
DM
113
114 $models = array();
115 foreach ($modelobjs as $modelobj) {
3a396286
DM
116 $model = new \core_analytics\model($modelobj);
117 if ($model->is_available()) {
118 $models[$modelobj->id] = $model;
119 }
369389c9
DM
120 }
121 return $models;
122 }
123
124 /**
ed12ba6b 125 * Returns the provided predictions processor class.
369389c9 126 *
ed12ba6b 127 * @param false|string $predictionclass Returns the system default processor if false
369389c9
DM
128 * @param bool $checkisready
129 * @return \core_analytics\predictor
130 */
131 public static function get_predictions_processor($predictionclass = false, $checkisready = true) {
132
133 // We want 0 or 1 so we can use it as an array key for caching.
134 $checkisready = intval($checkisready);
135
ed12ba6b 136 if (!$predictionclass) {
369389c9
DM
137 $predictionclass = get_config('analytics', 'predictionsprocessor');
138 }
139
140 if (empty($predictionclass)) {
141 // Use the default one if nothing set.
ed12ba6b 142 $predictionclass = self::default_mlbackend();
369389c9
DM
143 }
144
145 if (!class_exists($predictionclass)) {
146 throw new \coding_exception('Invalid predictions processor ' . $predictionclass . '.');
147 }
148
149 $interfaces = class_implements($predictionclass);
150 if (empty($interfaces['core_analytics\predictor'])) {
151 throw new \coding_exception($predictionclass . ' should implement \core_analytics\predictor.');
152 }
153
154 // Return it from the cached list.
155 if (!isset(self::$predictionprocessors[$checkisready][$predictionclass])) {
156
157 $instance = new $predictionclass();
158 if ($checkisready) {
159 $isready = $instance->is_ready();
160 if ($isready !== true) {
161 throw new \moodle_exception('errorprocessornotready', 'analytics', '', $isready);
162 }
163 }
164 self::$predictionprocessors[$checkisready][$predictionclass] = $instance;
165 }
166
167 return self::$predictionprocessors[$checkisready][$predictionclass];
168 }
169
1611308b
DM
170 /**
171 * Return all system predictions processors.
172 *
08015e18 173 * @return \core_analytics\predictor[]
1611308b 174 */
369389c9
DM
175 public static function get_all_prediction_processors() {
176
177 $mlbackends = \core_component::get_plugin_list('mlbackend');
178
179 $predictionprocessors = array();
180 foreach ($mlbackends as $mlbackend => $unused) {
206d7aa9 181 $classfullpath = '\mlbackend_' . $mlbackend . '\processor';
369389c9
DM
182 $predictionprocessors[$classfullpath] = self::get_predictions_processor($classfullpath, false);
183 }
184 return $predictionprocessors;
185 }
186
ed12ba6b
DM
187 /**
188 * Returns the name of the provided predictions processor.
189 *
190 * @param \core_analytics\predictor $predictionsprocessor
191 * @return string
192 */
193 public static function get_predictions_processor_name(\core_analytics\predictor $predictionsprocessor) {
194 $component = substr(get_class($predictionsprocessor), 0, strpos(get_class($predictionsprocessor), '\\', 1));
195 return get_string('pluginname', $component);
196 }
197
198 /**
199 * Whether the provided plugin is used by any model.
200 *
201 * @param string $plugin
202 * @return bool
203 */
204 public static function is_mlbackend_used($plugin) {
205 $models = self::get_all_models();
206 foreach ($models as $model) {
207 $processor = $model->get_predictions_processor();
208 $noprefixnamespace = ltrim(get_class($processor), '\\');
209 $processorplugin = substr($noprefixnamespace, 0, strpos($noprefixnamespace, '\\'));
210 if ($processorplugin == $plugin) {
211 return true;
212 }
213 }
214
215 // Default predictions processor.
216 $defaultprocessorclass = get_config('analytics', 'predictionsprocessor');
217 $pluginclass = '\\' . $plugin . '\\processor';
218 if ($pluginclass === $defaultprocessorclass) {
219 return true;
220 }
221
222 return false;
223 }
224
369389c9
DM
225 /**
226 * Get all available time splitting methods.
227 *
1cc2b4ba 228 * @return \core_analytics\local\time_splitting\base[]
369389c9
DM
229 */
230 public static function get_all_time_splittings() {
231 if (self::$alltimesplittings !== null) {
232 return self::$alltimesplittings;
233 }
234
235 $classes = self::get_analytics_classes('time_splitting');
236
237 self::$alltimesplittings = [];
238 foreach ($classes as $fullclassname => $classpath) {
239 $instance = self::get_time_splitting($fullclassname);
240 // We need to check that it is a valid time splitting method, it may be an abstract class.
241 if ($instance) {
242 self::$alltimesplittings[$instance->get_id()] = $instance;
243 }
244 }
245
246 return self::$alltimesplittings;
247 }
248
249 /**
250 * Returns the enabled time splitting methods.
251 *
252 * @return \core_analytics\local\time_splitting\base[]
253 */
254 public static function get_enabled_time_splitting_methods() {
255
256 if ($enabledtimesplittings = get_config('analytics', 'timesplittings')) {
257 $enabledtimesplittings = array_flip(explode(',', $enabledtimesplittings));
258 }
259
260 $timesplittings = self::get_all_time_splittings();
261 foreach ($timesplittings as $key => $timesplitting) {
262
263 // We remove the ones that are not enabled. This also respects the default value (all methods enabled).
264 if (!empty($enabledtimesplittings) && !isset($enabledtimesplittings[$key])) {
265 unset($timesplittings[$key]);
266 }
267 }
268 return $timesplittings;
269 }
270
271 /**
272 * Returns a time splitting method by its classname.
273 *
274 * @param string $fullclassname
275 * @return \core_analytics\local\time_splitting\base|false False if it is not valid.
276 */
277 public static function get_time_splitting($fullclassname) {
278 if (!self::is_valid($fullclassname, '\core_analytics\local\time_splitting\base')) {
279 return false;
280 }
281 return new $fullclassname();
282 }
283
284 /**
285 * Return all system indicators.
286 *
287 * @return \core_analytics\local\indicator\base[]
288 */
289 public static function get_all_indicators() {
290 if (self::$allindicators !== null) {
291 return self::$allindicators;
292 }
293
294 $classes = self::get_analytics_classes('indicator');
295
296 self::$allindicators = [];
297 foreach ($classes as $fullclassname => $classpath) {
298 $instance = self::get_indicator($fullclassname);
299 if ($instance) {
300 // Using get_class as get_component_classes_in_namespace returns double escaped fully qualified class names.
b0c24929 301 self::$allindicators[$instance->get_id()] = $instance;
369389c9
DM
302 }
303 }
304
305 return self::$allindicators;
306 }
307
1611308b
DM
308 /**
309 * Returns the specified target
310 *
311 * @param mixed $fullclassname
312 * @return \core_analytics\local\target\base|false False if it is not valid
313 */
369389c9
DM
314 public static function get_target($fullclassname) {
315 if (!self::is_valid($fullclassname, 'core_analytics\local\target\base')) {
316 return false;
317 }
318 return new $fullclassname();
319 }
320
321 /**
322 * Returns an instance of the provided indicator.
323 *
324 * @param string $fullclassname
325 * @return \core_analytics\local\indicator\base|false False if it is not valid.
326 */
327 public static function get_indicator($fullclassname) {
328 if (!self::is_valid($fullclassname, 'core_analytics\local\indicator\base')) {
329 return false;
330 }
331 return new $fullclassname();
332 }
333
334 /**
335 * Returns whether a time splitting method is valid or not.
336 *
337 * @param string $fullclassname
1611308b 338 * @param string $baseclass
369389c9
DM
339 * @return bool
340 */
341 public static function is_valid($fullclassname, $baseclass) {
342 if (is_subclass_of($fullclassname, $baseclass)) {
343 if ((new \ReflectionClass($fullclassname))->isInstantiable()) {
344 return true;
345 }
346 }
347 return false;
348 }
349
f67f35f3 350 /**
1611308b 351 * Returns the logstore used for analytics.
f67f35f3 352 *
2db6e981 353 * @return \core\log\sql_reader|false False if no log stores are enabled.
f67f35f3
DM
354 */
355 public static function get_analytics_logstore() {
356 $readers = get_log_manager()->get_readers('core\log\sql_reader');
357 $analyticsstore = get_config('analytics', 'logstore');
85e50ba4
DM
358
359 if (!empty($analyticsstore) && !empty($readers[$analyticsstore])) {
f67f35f3 360 $logstore = $readers[$analyticsstore];
85e50ba4
DM
361 } else if (empty($analyticsstore) && !empty($readers)) {
362 // The first one, it is the same default than in settings.
363 $logstore = reset($readers);
2db6e981 364 } else if (!empty($readers)) {
f67f35f3
DM
365 $logstore = reset($readers);
366 debugging('The selected log store for analytics is not available anymore. Using "' .
367 $logstore->get_name() . '"', DEBUG_DEVELOPER);
368 }
369
2db6e981
DM
370 if (empty($logstore)) {
371 debugging('No system log stores available to use for analytics', DEBUG_DEVELOPER);
372 return false;
373 }
374
f67f35f3
DM
375 if (!$logstore->is_logging()) {
376 debugging('The selected log store for analytics "' . $logstore->get_name() .
377 '" is not logging activity logs', DEBUG_DEVELOPER);
378 }
379
380 return $logstore;
381 }
382
0690a271
DM
383 /**
384 * Returns this analysable calculations during the provided period.
385 *
386 * @param \core_analytics\analysable $analysable
387 * @param int $starttime
388 * @param int $endtime
389 * @param string $samplesorigin The samples origin as sampleid is not unique across models.
390 * @return array
391 */
392 public static function get_indicator_calculations($analysable, $starttime, $endtime, $samplesorigin) {
393 global $DB;
394
395 $params = array('starttime' => $starttime, 'endtime' => $endtime, 'contextid' => $analysable->get_context()->id,
396 'sampleorigin' => $samplesorigin);
397 $calculations = $DB->get_recordset('analytics_indicator_calc', $params, '', 'indicator, sampleid, value');
398
399 $existingcalculations = array();
400 foreach ($calculations as $calculation) {
401 if (empty($existingcalculations[$calculation->indicator])) {
402 $existingcalculations[$calculation->indicator] = array();
403 }
404 $existingcalculations[$calculation->indicator][$calculation->sampleid] = $calculation->value;
405 }
a938e409 406 $calculations->close();
0690a271
DM
407 return $existingcalculations;
408 }
409
1611308b
DM
410 /**
411 * Returns the models with insights at the provided context.
412 *
413 * @param \context $context
414 * @return \core_analytics\model[]
415 */
416 public static function get_models_with_insights(\context $context) {
417
418 self::check_can_list_insights($context);
419
413f19bc 420 $models = self::get_all_models(true, true, $context);
1611308b
DM
421 foreach ($models as $key => $model) {
422 // Check that it not only have predictions but also generates insights from them.
423 if (!$model->uses_insights()) {
424 unset($models[$key]);
425 }
426 }
427 return $models;
428 }
429
430 /**
431 * Returns a prediction
432 *
433 * @param int $predictionid
434 * @param bool $requirelogin
435 * @return array array($model, $prediction, $context)
436 */
437 public static function get_prediction($predictionid, $requirelogin = false) {
438 global $DB;
439
440 if (!$predictionobj = $DB->get_record('analytics_predictions', array('id' => $predictionid))) {
4a210b06
DM
441 throw new \moodle_exception('errorpredictionnotfound', 'analytics');
442 }
443
444 $context = \context::instance_by_id($predictionobj->contextid, IGNORE_MISSING);
445 if (!$context) {
446 throw new \moodle_exception('errorpredictioncontextnotavailable', 'analytics');
1611308b
DM
447 }
448
449 if ($requirelogin) {
450 list($context, $course, $cm) = get_context_info_array($predictionobj->contextid);
451 require_login($course, false, $cm);
1611308b
DM
452 }
453
413f19bc 454 self::check_can_list_insights($context);
1611308b
DM
455
456 $model = new \core_analytics\model($predictionobj->modelid);
457 $sampledata = $model->prediction_sample_data($predictionobj);
458 $prediction = new \core_analytics\prediction($predictionobj, $sampledata);
459
460 return array($model, $prediction, $context);
461 }
462
e709e544
DM
463 /**
464 * Adds the models included with moodle core to the system.
465 *
466 * @return void
467 */
468 public static function add_builtin_models() {
469
690ad875 470 $target = self::get_target('\core\analytics\target\course_dropout');
e709e544
DM
471
472 // Community of inquiry indicators.
473 $coiindicators = array(
474 '\mod_assign\analytics\indicator\cognitive_depth',
475 '\mod_assign\analytics\indicator\social_breadth',
476 '\mod_book\analytics\indicator\cognitive_depth',
477 '\mod_book\analytics\indicator\social_breadth',
478 '\mod_chat\analytics\indicator\cognitive_depth',
479 '\mod_chat\analytics\indicator\social_breadth',
480 '\mod_choice\analytics\indicator\cognitive_depth',
481 '\mod_choice\analytics\indicator\social_breadth',
482 '\mod_data\analytics\indicator\cognitive_depth',
483 '\mod_data\analytics\indicator\social_breadth',
484 '\mod_feedback\analytics\indicator\cognitive_depth',
485 '\mod_feedback\analytics\indicator\social_breadth',
486 '\mod_folder\analytics\indicator\cognitive_depth',
487 '\mod_folder\analytics\indicator\social_breadth',
488 '\mod_forum\analytics\indicator\cognitive_depth',
489 '\mod_forum\analytics\indicator\social_breadth',
490 '\mod_glossary\analytics\indicator\cognitive_depth',
491 '\mod_glossary\analytics\indicator\social_breadth',
492 '\mod_imscp\analytics\indicator\cognitive_depth',
493 '\mod_imscp\analytics\indicator\social_breadth',
494 '\mod_label\analytics\indicator\cognitive_depth',
495 '\mod_label\analytics\indicator\social_breadth',
496 '\mod_lesson\analytics\indicator\cognitive_depth',
497 '\mod_lesson\analytics\indicator\social_breadth',
498 '\mod_lti\analytics\indicator\cognitive_depth',
499 '\mod_lti\analytics\indicator\social_breadth',
500 '\mod_page\analytics\indicator\cognitive_depth',
501 '\mod_page\analytics\indicator\social_breadth',
502 '\mod_quiz\analytics\indicator\cognitive_depth',
503 '\mod_quiz\analytics\indicator\social_breadth',
504 '\mod_resource\analytics\indicator\cognitive_depth',
505 '\mod_resource\analytics\indicator\social_breadth',
506 '\mod_scorm\analytics\indicator\cognitive_depth',
507 '\mod_scorm\analytics\indicator\social_breadth',
508 '\mod_survey\analytics\indicator\cognitive_depth',
509 '\mod_survey\analytics\indicator\social_breadth',
510 '\mod_url\analytics\indicator\cognitive_depth',
511 '\mod_url\analytics\indicator\social_breadth',
512 '\mod_wiki\analytics\indicator\cognitive_depth',
513 '\mod_wiki\analytics\indicator\social_breadth',
514 '\mod_workshop\analytics\indicator\cognitive_depth',
515 '\mod_workshop\analytics\indicator\social_breadth',
0328f12b
DM
516 '\core_course\analytics\indicator\completion_enabled',
517 '\core_course\analytics\indicator\potential_cognitive_depth',
518 '\core_course\analytics\indicator\potential_social_breadth',
519 '\core\analytics\indicator\any_access_after_end',
520 '\core\analytics\indicator\any_access_before_start',
6e98bca0 521 '\core\analytics\indicator\any_write_action_in_course',
0328f12b 522 '\core\analytics\indicator\read_actions',
e709e544
DM
523 );
524 $indicators = array();
525 foreach ($coiindicators as $coiindicator) {
690ad875 526 $indicator = self::get_indicator($coiindicator);
e709e544
DM
527 $indicators[$indicator->get_id()] = $indicator;
528 }
529 if (!\core_analytics\model::exists($target, $indicators)) {
530 $model = \core_analytics\model::create($target, $indicators);
531 }
532
533 // No teaching model.
690ad875 534 $target = self::get_target('\core\analytics\target\no_teaching');
e709e544 535 $timesplittingmethod = '\core\analytics\time_splitting\single_range';
690ad875 536 $noteacher = self::get_indicator('\core_course\analytics\indicator\no_teacher');
0927604f
DM
537 $nostudent = self::get_indicator('\core_course\analytics\indicator\no_student');
538 $indicators = array($noteacher->get_id() => $noteacher, $nostudent->get_id() => $nostudent);
e709e544
DM
539 if (!\core_analytics\model::exists($target, $indicators)) {
540 \core_analytics\model::create($target, $indicators, $timesplittingmethod);
541 }
542 }
543
f9222c49
DM
544 /**
545 * Cleans up analytics db tables that do not directly depend on analysables that may have been deleted.
546 */
547 public static function cleanup() {
548 global $DB;
549
550 // Clean up stuff that depends on contexts that do not exist anymore.
551 $sql = "SELECT DISTINCT ap.contextid FROM {analytics_predictions} ap
552 LEFT JOIN {context} ctx ON ap.contextid = ctx.id
553 WHERE ctx.id IS NULL";
554 $apcontexts = $DB->get_records_sql($sql);
555
556 $sql = "SELECT DISTINCT aic.contextid FROM {analytics_indicator_calc} aic
557 LEFT JOIN {context} ctx ON aic.contextid = ctx.id
558 WHERE ctx.id IS NULL";
559 $indcalccontexts = $DB->get_records_sql($sql);
560
561 $contexts = $apcontexts + $indcalccontexts;
562 if ($contexts) {
563 list($sql, $params) = $DB->get_in_or_equal(array_keys($contexts));
e08c74f5 564 $DB->execute("DELETE FROM {analytics_prediction_actions} WHERE predictionid IN
f9222c49
DM
565 (SELECT ap.id FROM {analytics_predictions} ap WHERE ap.contextid $sql)", $params);
566
567 $DB->delete_records_select('analytics_predictions', "contextid $sql", $params);
568 $DB->delete_records_select('analytics_indicator_calc', "contextid $sql", $params);
569 }
570
571 // Clean up stuff that depends on analysable ids that do not exist anymore.
572 $models = self::get_all_models();
573 foreach ($models as $model) {
574 $analyser = $model->get_analyser(array('notimesplitting' => true));
575 $analysables = $analyser->get_analysables();
576 if (!$analysables) {
577 continue;
578 }
579
580 $analysableids = array_map(function($analysable) {
581 return $analysable->get_id();
582 }, $analysables);
583
584 list($notinsql, $params) = $DB->get_in_or_equal($analysableids, SQL_PARAMS_NAMED, 'param', false);
585 $params['modelid'] = $model->get_id();
586
587 $DB->delete_records_select('analytics_predict_samples', "modelid = :modelid AND analysableid $notinsql", $params);
588 $DB->delete_records_select('analytics_train_samples', "modelid = :modelid AND analysableid $notinsql", $params);
589 }
590 }
591
ed12ba6b
DM
592 /**
593 * Default system backend.
594 *
595 * @return string
596 */
597 public static function default_mlbackend() {
598 return self::DEFAULT_MLBACKEND;
599 }
600
369389c9
DM
601 /**
602 * Returns the provided element classes in the site.
603 *
604 * @param string $element
605 * @return string[] Array keys are the FQCN and the values the class path.
606 */
607 private static function get_analytics_classes($element) {
608
609 // Just in case...
1611308b 610 $element = clean_param($element, PARAM_ALPHANUMEXT);
369389c9 611
206d7aa9
DM
612 // Core analytics classes (analytics subsystem should not contain uses of the analytics API).
613 $classes = \core_component::get_component_classes_in_namespace('core', 'analytics\\' . $element);
614
615 // Plugins.
369389c9
DM
616 foreach (\core_component::get_plugin_types() as $type => $unusedplugintypepath) {
617 foreach (\core_component::get_plugin_list($type) as $pluginname => $unusedpluginpath) {
618 $frankenstyle = $type . '_' . $pluginname;
619 $classes += \core_component::get_component_classes_in_namespace($frankenstyle, 'analytics\\' . $element);
620 }
621 }
1cc2b4ba 622
206d7aa9 623 // Core subsystems.
1cc2b4ba
DM
624 foreach (\core_component::get_core_subsystems() as $subsystemname => $unusedsubsystempath) {
625 $componentname = 'core_' . $subsystemname;
626 $classes += \core_component::get_component_classes_in_namespace($componentname, 'analytics\\' . $element);
627 }
628
369389c9
DM
629 return $classes;
630 }
631}