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