Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
/**
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
 */
24
 
25
namespace core_analytics;
26
 
27
defined('MOODLE_INTERNAL') || die();
28
 
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 {
37
 
38
    /**
39
     * Default mlbackend
40
     */
1441 ariadna 41
    const DEFAULT_MLBACKEND = '\mlbackend_python\processor';
1 efrain 42
 
43
    /**
44
     * Name of the file where components declare their models.
45
     */
46
    const ANALYTICS_FILENAME = 'db/analytics.php';
47
 
48
    /**
49
     * @var \core_analytics\predictor[]
50
     */
51
    protected static $predictionprocessors = [];
52
 
53
    /**
54
     * @var \core_analytics\local\target\base[]
55
     */
56
    protected static $alltargets = null;
57
 
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
 
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
     * @param  bool $return The method returns a bool if true.
84
     * @return void
85
     */
86
    public static function check_can_list_insights(\context $context, bool $return = false) {
87
        global $USER;
88
 
89
        if ($context->contextlevel === CONTEXT_USER && $context->instanceid == $USER->id) {
90
            $capability = 'moodle/analytics:listowninsights';
91
        } else {
92
            $capability = 'moodle/analytics:listinsights';
93
        }
94
 
95
        if ($return) {
96
            return has_capability($capability, $context);
97
        } else {
98
            require_capability($capability, $context);
99
        }
100
    }
101
 
102
    /**
103
     * Is analytics enabled globally?
104
     *
105
     * return bool
106
     */
107
    public static function is_analytics_enabled(): bool {
108
        global $CFG;
109
 
110
        if (isset($CFG->enableanalytics)) {
111
            return $CFG->enableanalytics;
112
        }
113
 
114
        // Enabled by default.
115
        return true;
116
    }
117
 
118
    /**
119
     * Returns all system models that match the provided filters.
120
     *
121
     * @param bool $enabled
122
     * @param bool $trained
123
     * @param \context|false $predictioncontext
124
     * @return \core_analytics\model[]
125
     */
126
    public static function get_all_models($enabled = false, $trained = false, $predictioncontext = false) {
127
        global $DB;
128
 
129
        $params = array();
130
 
131
        $sql = "SELECT am.* FROM {analytics_models} am";
132
 
133
        if ($enabled || $trained || $predictioncontext) {
134
            $conditions = [];
135
            if ($enabled) {
136
                $conditions[] = 'am.enabled = :enabled';
137
                $params['enabled'] = 1;
138
            }
139
            if ($trained) {
140
                $conditions[] = 'am.trained = :trained';
141
                $params['trained'] = 1;
142
            }
143
            if ($predictioncontext) {
144
                $conditions[] = "EXISTS (SELECT 'x'
145
                                           FROM {analytics_predictions} ap
146
                                          WHERE ap.modelid = am.id AND ap.contextid = :contextid)";
147
                $params['contextid'] = $predictioncontext->id;
148
            }
149
            $sql .= ' WHERE ' . implode(' AND ', $conditions);
150
        }
151
        $sql .= ' ORDER BY am.enabled DESC, am.timemodified DESC';
152
 
153
        $modelobjs = $DB->get_records_sql($sql, $params);
154
 
155
        $models = array();
156
        foreach ($modelobjs as $modelobj) {
157
            $model = new \core_analytics\model($modelobj);
158
            if ($model->is_available()) {
159
                $models[$modelobj->id] = $model;
160
            }
161
        }
162
 
163
        // Sort the models by the model name using the current session language.
164
        \core_collator::asort_objects_by_method($models, 'get_name');
165
 
166
        return $models;
167
    }
168
 
169
    /**
170
     * Returns the provided predictions processor class.
171
     *
172
     * @param false|string $predictionclass Returns the system default processor if false
173
     * @param bool $checkisready
174
     * @return \core_analytics\predictor
175
     */
176
    public static function get_predictions_processor($predictionclass = false, $checkisready = true) {
177
 
178
        // We want 0 or 1 so we can use it as an array key for caching.
179
        $checkisready = intval($checkisready);
180
 
181
        if (!$predictionclass) {
182
            $predictionclass = get_config('analytics', 'predictionsprocessor');
183
        }
184
 
185
        if (empty($predictionclass)) {
186
            // Use the default one if nothing set.
187
            $predictionclass = self::default_mlbackend();
188
        }
189
 
190
        if (!class_exists($predictionclass)) {
191
            throw new \coding_exception('Invalid predictions processor ' . $predictionclass . '.');
192
        }
193
 
194
        $interfaces = class_implements($predictionclass);
195
        if (empty($interfaces['core_analytics\predictor'])) {
196
            throw new \coding_exception($predictionclass . ' should implement \core_analytics\predictor.');
197
        }
198
 
199
        // Return it from the cached list.
200
        if (!isset(self::$predictionprocessors[$checkisready][$predictionclass])) {
201
 
202
            $instance = new $predictionclass();
203
            if ($checkisready) {
204
                $isready = $instance->is_ready();
205
                if ($isready !== true) {
206
                    throw new \moodle_exception('errorprocessornotready', 'analytics', '', $isready);
207
                }
208
            }
209
            self::$predictionprocessors[$checkisready][$predictionclass] = $instance;
210
        }
211
 
212
        return self::$predictionprocessors[$checkisready][$predictionclass];
213
    }
214
 
215
    /**
216
     * Return all system predictions processors.
217
     *
218
     * @return \core_analytics\predictor[]
219
     */
220
    public static function get_all_prediction_processors() {
221
 
222
        $mlbackends = \core_component::get_plugin_list('mlbackend');
223
 
224
        $predictionprocessors = array();
225
        foreach ($mlbackends as $mlbackend => $unused) {
226
            $classfullpath = '\mlbackend_' . $mlbackend . '\processor';
227
            $predictionprocessors[$classfullpath] = self::get_predictions_processor($classfullpath, false);
228
        }
229
        return $predictionprocessors;
230
    }
231
 
232
    /**
233
     * Resets the cached prediction processors.
234
     * @return null
235
     */
236
    public static function reset_prediction_processors() {
237
        self::$predictionprocessors = [];
238
    }
239
 
240
    /**
241
     * Returns the name of the provided predictions processor.
242
     *
243
     * @param \core_analytics\predictor $predictionsprocessor
244
     * @return string
245
     */
246
    public static function get_predictions_processor_name(\core_analytics\predictor $predictionsprocessor) {
247
            $component = substr(get_class($predictionsprocessor), 0, strpos(get_class($predictionsprocessor), '\\', 1));
248
        return get_string('pluginname', $component);
249
    }
250
 
251
    /**
252
     * Whether the provided plugin is used by any model.
253
     *
254
     * @param string $plugin
255
     * @return bool
256
     */
257
    public static function is_mlbackend_used($plugin) {
258
        $models = self::get_all_models();
259
        foreach ($models as $model) {
1441 ariadna 260
            try {
261
                $processor = $model->get_predictions_processor();
262
                $noprefixnamespace = ltrim(get_class($processor), '\\');
263
                $processorplugin = substr($noprefixnamespace, 0, strpos($noprefixnamespace, '\\'));
264
                if ($processorplugin == $plugin) {
265
                    return true;
266
                }
267
            } catch (\Exception $e) {
268
                // The model does not have a predictions processor.
269
                continue;
1 efrain 270
            }
271
        }
272
 
273
        // Default predictions processor.
274
        $defaultprocessorclass = get_config('analytics', 'predictionsprocessor');
275
        $pluginclass = '\\' . $plugin . '\\processor';
276
        if ($pluginclass === $defaultprocessorclass) {
277
            return true;
278
        }
279
 
280
        return false;
281
    }
282
 
283
    /**
284
     * Get all available time splitting methods.
285
     *
286
     * @return \core_analytics\local\time_splitting\base[]
287
     */
288
    public static function get_all_time_splittings() {
289
        if (self::$alltimesplittings !== null) {
290
            return self::$alltimesplittings;
291
        }
292
 
293
        $classes = self::get_analytics_classes('time_splitting');
294
 
295
        self::$alltimesplittings = [];
296
        foreach ($classes as $fullclassname => $classpath) {
297
            $instance = self::get_time_splitting($fullclassname);
298
            // We need to check that it is a valid time splitting method, it may be an abstract class.
299
            if ($instance) {
300
                self::$alltimesplittings[$instance->get_id()] = $instance;
301
            }
302
        }
303
 
304
        return self::$alltimesplittings;
305
    }
306
 
307
    /**
308
     * Returns the time-splitting methods for model evaluation.
309
     *
310
     * @param  bool $all Return all the time-splitting methods that can potentially be used for evaluation or the default ones.
311
     * @return \core_analytics\local\time_splitting\base[]
312
     */
313
    public static function get_time_splitting_methods_for_evaluation(bool $all = false) {
314
 
315
        if ($all === false) {
316
            if ($enabledtimesplittings = get_config('analytics', 'defaulttimesplittingsevaluation')) {
317
                $enabledtimesplittings = array_flip(explode(',', $enabledtimesplittings));
318
            }
319
        }
320
 
321
        $timesplittings = self::get_all_time_splittings();
322
        foreach ($timesplittings as $key => $timesplitting) {
323
 
324
            if (!$timesplitting->valid_for_evaluation()) {
325
                unset($timesplittings[$key]);
326
            }
327
 
328
            if ($all === false) {
329
                // We remove the ones that are not enabled. This also respects the default value (all methods enabled).
330
                if (!empty($enabledtimesplittings) && !isset($enabledtimesplittings[$key])) {
331
                    unset($timesplittings[$key]);
332
                }
333
            }
334
        }
335
        return $timesplittings;
336
    }
337
 
338
    /**
339
     * Returns a time splitting method by its classname.
340
     *
341
     * @param string $fullclassname
342
     * @return \core_analytics\local\time_splitting\base|false False if it is not valid.
343
     */
344
    public static function get_time_splitting($fullclassname) {
345
        if (!self::is_valid($fullclassname, '\core_analytics\local\time_splitting\base')) {
346
            return false;
347
        }
348
        return new $fullclassname();
349
    }
350
 
351
    /**
352
     * Return all targets in the system.
353
     *
354
     * @return \core_analytics\local\target\base[]
355
     */
356
    public static function get_all_targets(): array {
357
        if (self::$alltargets !== null) {
358
            return self::$alltargets;
359
        }
360
 
361
        $classes = self::get_analytics_classes('target');
362
 
363
        self::$alltargets = [];
364
        foreach ($classes as $fullclassname => $classpath) {
365
            $instance = self::get_target($fullclassname);
366
            if ($instance) {
367
                self::$alltargets[$instance->get_id()] = $instance;
368
            }
369
        }
370
 
371
        return self::$alltargets;
372
    }
373
    /**
374
     * Return all system indicators.
375
     *
376
     * @return \core_analytics\local\indicator\base[]
377
     */
378
    public static function get_all_indicators() {
379
        if (self::$allindicators !== null) {
380
            return self::$allindicators;
381
        }
382
 
383
        $classes = self::get_analytics_classes('indicator');
384
 
385
        self::$allindicators = [];
386
        foreach ($classes as $fullclassname => $classpath) {
387
            $instance = self::get_indicator($fullclassname);
388
            if ($instance) {
389
                self::$allindicators[$instance->get_id()] = $instance;
390
            }
391
        }
392
 
393
        return self::$allindicators;
394
    }
395
 
396
    /**
397
     * Returns the specified target
398
     *
399
     * @param mixed $fullclassname
400
     * @return \core_analytics\local\target\base|false False if it is not valid
401
     */
402
    public static function get_target($fullclassname) {
403
        if (!self::is_valid($fullclassname, 'core_analytics\local\target\base')) {
404
            return false;
405
        }
406
        return new $fullclassname();
407
    }
408
 
409
    /**
410
     * Returns an instance of the provided indicator.
411
     *
412
     * @param string $fullclassname
413
     * @return \core_analytics\local\indicator\base|false False if it is not valid.
414
     */
415
    public static function get_indicator($fullclassname) {
416
        if (!self::is_valid($fullclassname, 'core_analytics\local\indicator\base')) {
417
            return false;
418
        }
419
        return new $fullclassname();
420
    }
421
 
422
    /**
423
     * Returns whether a time splitting method is valid or not.
424
     *
425
     * @param string $fullclassname
426
     * @param string $baseclass
427
     * @return bool
428
     */
429
    public static function is_valid($fullclassname, $baseclass) {
430
        if (is_subclass_of($fullclassname, $baseclass)) {
431
            if ((new \ReflectionClass($fullclassname))->isInstantiable()) {
432
                return true;
433
            }
434
        }
435
        return false;
436
    }
437
 
438
    /**
439
     * Returns the logstore used for analytics.
440
     *
441
     * @return \core\log\sql_reader|false False if no log stores are enabled.
442
     */
443
    public static function get_analytics_logstore() {
444
        $readers = get_log_manager()->get_readers('core\log\sql_reader');
445
        $analyticsstore = get_config('analytics', 'logstore');
446
 
447
        if (!empty($analyticsstore) && !empty($readers[$analyticsstore])) {
448
            $logstore = $readers[$analyticsstore];
449
        } else if (empty($analyticsstore) && !empty($readers)) {
450
            // The first one, it is the same default than in settings.
451
            $logstore = reset($readers);
452
        } else if (!empty($readers)) {
453
            $logstore = reset($readers);
454
            debugging('The selected log store for analytics is not available anymore. Using "' .
455
                $logstore->get_name() . '"', DEBUG_DEVELOPER);
456
        }
457
 
458
        if (empty($logstore)) {
459
            debugging('No system log stores available to use for analytics', DEBUG_DEVELOPER);
460
            return false;
461
        }
462
 
463
        if (!$logstore->is_logging()) {
464
            debugging('The selected log store for analytics "' . $logstore->get_name() .
465
                '" is not logging activity logs', DEBUG_DEVELOPER);
466
        }
467
 
468
        return $logstore;
469
    }
470
 
471
    /**
472
     * Returns this analysable calculations during the provided period.
473
     *
474
     * @param \core_analytics\analysable $analysable
475
     * @param int $starttime
476
     * @param int $endtime
477
     * @param string $samplesorigin The samples origin as sampleid is not unique across models.
478
     * @return array
479
     */
480
    public static function get_indicator_calculations($analysable, $starttime, $endtime, $samplesorigin) {
481
        global $DB;
482
 
483
        $params = array('starttime' => $starttime, 'endtime' => $endtime, 'contextid' => $analysable->get_context()->id,
484
            'sampleorigin' => $samplesorigin);
485
        $calculations = $DB->get_recordset('analytics_indicator_calc', $params, '', 'indicator, sampleid, value');
486
 
487
        $existingcalculations = array();
488
        foreach ($calculations as $calculation) {
489
            if (empty($existingcalculations[$calculation->indicator])) {
490
                $existingcalculations[$calculation->indicator] = array();
491
            }
492
            $existingcalculations[$calculation->indicator][$calculation->sampleid] = $calculation->value;
493
        }
494
        $calculations->close();
495
        return $existingcalculations;
496
    }
497
 
498
    /**
499
     * Returns the models with insights at the provided context.
500
     *
501
     * Note that this method is used for display purposes. It filters out models whose insights
502
     * are not linked from the reports page.
503
     *
504
     * @param \context $context
505
     * @return \core_analytics\model[]
506
     */
507
    public static function get_models_with_insights(\context $context) {
508
 
509
        self::check_can_list_insights($context);
510
 
511
        $models = self::get_all_models(true, true, $context);
512
        foreach ($models as $key => $model) {
513
            // Check that it not only have predictions but also generates insights from them.
514
            if (!$model->uses_insights() || !$model->get_target()->link_insights_report()) {
515
                unset($models[$key]);
516
            }
517
        }
518
        return $models;
519
    }
520
 
521
    /**
522
     * Returns the models that generated insights in the provided context. It can also be used to add new models to the context.
523
     *
524
     * Note that if you use this function with $newmodelid is the caller responsibility to ensure that the
525
     * provided model id generated insights for the provided context.
526
     *
527
     * @throws \coding_exception
528
     * @param  \context $context
529
     * @param  int|null $newmodelid A new model to add to the list of models with insights in the provided context.
530
     * @return int[]
531
     */
1441 ariadna 532
    public static function cached_models_with_insights(\context $context, ?int $newmodelid = null) {
1 efrain 533
 
534
        $cache = \cache::make('core', 'contextwithinsights');
535
        $modelids = $cache->get($context->id);
536
        if ($modelids === false) {
537
            // The cache is empty, but we don't know if it is empty because there are no insights
538
            // in this context or because cache/s have been purged, we need to be conservative and
539
            // "pay" 1 db read to fill up the cache.
540
 
541
            $models = \core_analytics\manager::get_models_with_insights($context);
542
 
543
            if ($newmodelid && empty($models[$newmodelid])) {
544
                throw new \coding_exception('The provided modelid ' . $newmodelid . ' did not generate any insights');
545
            }
546
 
547
            $modelids = array_keys($models);
548
            $cache->set($context->id, $modelids);
549
 
550
        } else if ($newmodelid && !in_array($newmodelid, $modelids)) {
551
            // We add the context we got as an argument to the cache.
552
 
553
            array_push($modelids, $newmodelid);
554
            $cache->set($context->id, $modelids);
555
        }
556
 
557
        return $modelids;
558
    }
559
 
560
    /**
561
     * Returns a prediction
562
     *
563
     * @param int $predictionid
564
     * @param bool $requirelogin
565
     * @return array array($model, $prediction, $context)
566
     */
567
    public static function get_prediction($predictionid, $requirelogin = false) {
568
        global $DB;
569
 
570
        if (!$predictionobj = $DB->get_record('analytics_predictions', array('id' => $predictionid))) {
571
            throw new \moodle_exception('errorpredictionnotfound', 'analytics');
572
        }
573
 
574
        $context = \context::instance_by_id($predictionobj->contextid, IGNORE_MISSING);
575
        if (!$context) {
576
            throw new \moodle_exception('errorpredictioncontextnotavailable', 'analytics');
577
        }
578
 
579
        if ($requirelogin) {
580
            list($context, $course, $cm) = get_context_info_array($predictionobj->contextid);
581
            require_login($course, false, $cm);
582
        }
583
 
584
        self::check_can_list_insights($context);
585
 
586
        $model = new \core_analytics\model($predictionobj->modelid);
587
        $sampledata = $model->prediction_sample_data($predictionobj);
588
        $prediction = new \core_analytics\prediction($predictionobj, $sampledata);
589
 
590
        return array($model, $prediction, $context);
591
    }
592
 
593
    /**
594
     * Used to be used to add models included with the Moodle core.
595
     *
596
     * @deprecated Deprecated since Moodle 3.7 (MDL-61667) - Use lib/db/analytics.php instead.
597
     * @todo Remove this method in Moodle 3.11 (MDL-65186).
598
     * @return void
599
     */
600
    public static function add_builtin_models() {
601
 
602
        throw new \coding_exception('core_analytics\manager::add_builtin_models() has been removed. Core models ' .
603
                        'are now automatically updated according to their declaration in the lib/db/analytics.php file.');
604
    }
605
 
606
    /**
607
     * Cleans up analytics db tables that do not directly depend on analysables that may have been deleted.
608
     */
609
    public static function cleanup() {
610
        global $DB;
611
 
612
        $DB->execute("DELETE FROM {analytics_prediction_actions} WHERE predictionid IN
613
                          (SELECT ap.id FROM {analytics_predictions} ap
614
                        LEFT JOIN {context} ctx ON ap.contextid = ctx.id
615
                            WHERE ctx.id IS NULL)");
616
 
617
        // Cleanup analaytics predictions/calcs with MySQL friendly sub-select.
618
        $DB->execute("DELETE FROM {analytics_predictions} WHERE id IN (
619
                        SELECT oldpredictions.id
620
                        FROM (
621
                            SELECT p.id
622
                            FROM {analytics_predictions} p
623
                            LEFT JOIN {context} ctx ON p.contextid = ctx.id
624
                            WHERE ctx.id IS NULL
625
                        ) oldpredictions
626
                    )");
627
 
628
        $DB->execute("DELETE FROM {analytics_indicator_calc} WHERE id IN (
629
                        SELECT oldcalcs.id FROM (
630
                            SELECT c.id
631
                            FROM {analytics_indicator_calc} c
632
                            LEFT JOIN {context} ctx ON c.contextid = ctx.id
633
                            WHERE ctx.id IS NULL
634
                        ) oldcalcs
635
                    )");
636
 
637
        // Clean up stuff that depends on analysable ids that do not exist anymore.
638
 
639
        $models = self::get_all_models();
640
        foreach ($models as $model) {
641
 
642
            // We first dump into memory the list of analysables we have in the database (we could probably do this with 1 single
643
            // query for the 3 tables, but it may be safer to do it separately).
644
            $predictsamplesanalysableids = $DB->get_fieldset_select('analytics_predict_samples', 'DISTINCT analysableid',
645
                'modelid = :modelid', ['modelid' => $model->get_id()]);
646
            $predictsamplesanalysableids = array_flip($predictsamplesanalysableids);
647
            $trainsamplesanalysableids = $DB->get_fieldset_select('analytics_train_samples', 'DISTINCT analysableid',
648
                'modelid = :modelid', ['modelid' => $model->get_id()]);
649
            $trainsamplesanalysableids = array_flip($trainsamplesanalysableids);
650
            $usedanalysablesanalysableids = $DB->get_fieldset_select('analytics_used_analysables', 'DISTINCT analysableid',
651
                'modelid = :modelid', ['modelid' => $model->get_id()]);
652
            $usedanalysablesanalysableids = array_flip($usedanalysablesanalysableids);
653
 
654
            $analyser = $model->get_analyser(array('notimesplitting' => true));
655
 
656
            // We do not honour the list of contexts in this model as it can contain stale records.
657
            $analysables = $analyser->get_analysables_iterator();
658
 
659
            $analysableids = [];
660
            foreach ($analysables as $analysable) {
661
                if (!$analysable) {
662
                    continue;
663
                }
664
                unset($predictsamplesanalysableids[$analysable->get_id()]);
665
                unset($trainsamplesanalysableids[$analysable->get_id()]);
666
                unset($usedanalysablesanalysableids[$analysable->get_id()]);
667
            }
668
 
669
            $param = ['modelid' => $model->get_id()];
670
 
671
            if ($predictsamplesanalysableids) {
672
                list($idssql, $idsparams) = $DB->get_in_or_equal(array_flip($predictsamplesanalysableids), SQL_PARAMS_NAMED);
673
                $DB->delete_records_select('analytics_predict_samples', "modelid = :modelid AND analysableid $idssql",
674
                    $param + $idsparams);
675
            }
676
            if ($trainsamplesanalysableids) {
677
                list($idssql, $idsparams) = $DB->get_in_or_equal(array_flip($trainsamplesanalysableids), SQL_PARAMS_NAMED);
678
                $DB->delete_records_select('analytics_train_samples', "modelid = :modelid AND analysableid $idssql",
679
                    $param + $idsparams);
680
            }
681
            if ($usedanalysablesanalysableids) {
682
                list($idssql, $idsparams) = $DB->get_in_or_equal(array_flip($usedanalysablesanalysableids), SQL_PARAMS_NAMED);
683
                $DB->delete_records_select('analytics_used_analysables', "modelid = :modelid AND analysableid $idssql",
684
                    $param + $idsparams);
685
            }
686
        }
687
 
688
        // Clean up calculations table.
689
        $calclifetime = get_config('analytics', 'calclifetime');
690
        if (!empty($calclifetime)) {
691
            $lifetime = time() - ($calclifetime * DAYSECS); // Value in days.
692
            $DB->delete_records_select('analytics_indicator_calc', 'timecreated < ?', [$lifetime]);
693
        }
694
    }
695
 
696
    /**
697
     * Default system backend.
698
     *
699
     * @return string
700
     */
701
    public static function default_mlbackend() {
702
        return self::DEFAULT_MLBACKEND;
703
    }
704
 
705
    /**
706
     * Returns the provided element classes in the site.
707
     *
708
     * @param string $element
709
     * @return string[] Array keys are the FQCN and the values the class path.
710
     */
711
    private static function get_analytics_classes($element) {
712
 
713
        // Just in case...
714
        $element = clean_param($element, PARAM_ALPHANUMEXT);
715
 
716
        $classes = \core_component::get_component_classes_in_namespace(null, 'analytics\\' . $element);
717
 
718
        return $classes;
719
    }
720
 
721
    /**
722
     * Check that all the models declared by the component are up to date.
723
     *
724
     * This is intended to be called during the installation / upgrade to automatically create missing models.
725
     *
726
     * @param string $componentname The name of the component to load models for.
727
     * @return array \core_analytics\model[] List of actually created models.
728
     */
729
    public static function update_default_models_for_component(string $componentname): array {
730
 
731
        $result = [];
732
 
733
        foreach (static::load_default_models_for_component($componentname) as $definition) {
734
            if (!\core_analytics\model::exists(static::get_target($definition['target']))) {
735
                $result[] = static::create_declared_model($definition);
736
            }
737
        }
738
 
739
        return $result;
740
    }
741
 
742
    /**
743
     * Return the list of models declared by the given component.
744
     *
745
     * @param string $componentname The name of the component to load models for.
746
     * @throws \coding_exception Exception thrown in case of invalid syntax.
747
     * @return array The $models description array.
748
     */
749
    public static function load_default_models_for_component(string $componentname): array {
750
 
751
        $dir = \core_component::get_component_directory($componentname);
752
 
753
        if (!$dir) {
754
            // This is either an invalid component, or a core subsystem without its own root directory.
755
            return [];
756
        }
757
 
758
        $file = $dir . '/' . self::ANALYTICS_FILENAME;
759
 
760
        if (!is_readable($file)) {
761
            return [];
762
        }
763
 
764
        $models = null;
765
        include($file);
766
 
767
        if (!isset($models) || !is_array($models) || empty($models)) {
768
            return [];
769
        }
770
 
771
        foreach ($models as &$model) {
772
            if (!isset($model['enabled'])) {
773
                $model['enabled'] = false;
774
            } else {
775
                $model['enabled'] = clean_param($model['enabled'], PARAM_BOOL);
776
            }
1441 ariadna 777
 
778
            // For the core models only, automatically remove references to modules that do not
779
            // exist. This allows you to install without error if there are missing plugins.
780
            if ($componentname === 'moodle') {
781
                $updatedindicators = [];
782
                $allmodules = [];
783
                foreach ($model['indicators'] as $indicator) {
784
                    if (preg_match('~^\\\\mod_([^\\\\]+)\\\\~', $indicator, $matches)) {
785
                        if (!$allmodules) {
786
                            // The first time, get all modules.
787
                            $allmodules = \core\plugin_manager::instance()->get_present_plugins('mod');
788
                        }
789
                        if (!array_key_exists($matches[1], $allmodules)) {
790
                            // Module does not exist, so skip indicator.
791
                            continue;
792
                        }
793
                    }
794
                    $updatedindicators[] = $indicator;
795
                }
796
                $model['indicators'] = $updatedindicators;
797
            }
1 efrain 798
        }
799
 
800
        static::validate_models_declaration($models);
801
 
802
        return $models;
803
    }
804
 
805
    /**
806
     * Return the list of all the models declared anywhere in this Moodle installation.
807
     *
808
     * Models defined by the core and core subsystems come first, followed by those provided by plugins.
809
     *
810
     * @return array indexed by the frankenstyle component
811
     */
812
    public static function load_default_models_for_all_components(): array {
813
 
814
        $tmp = [];
815
 
816
        foreach (\core_component::get_component_list() as $type => $components) {
817
            foreach (array_keys($components) as $component) {
818
                if ($loaded = static::load_default_models_for_component($component)) {
819
                    $tmp[$type][$component] = $loaded;
820
                }
821
            }
822
        }
823
 
824
        $result = [];
825
 
826
        if ($loaded = static::load_default_models_for_component('core')) {
827
            $result['core'] = $loaded;
828
        }
829
 
830
        if (!empty($tmp['core'])) {
831
            $result += $tmp['core'];
832
            unset($tmp['core']);
833
        }
834
 
835
        foreach ($tmp as $components) {
836
            $result += $components;
837
        }
838
 
839
        return $result;
840
    }
841
 
842
    /**
843
     * Validate the declaration of prediction models according the syntax expected in the component's db folder.
844
     *
845
     * The expected structure looks like this:
846
     *
847
     *  [
848
     *      [
849
     *          'target' => '\fully\qualified\name\of\the\target\class',
850
     *          'indicators' => [
851
     *              '\fully\qualified\name\of\the\first\indicator',
852
     *              '\fully\qualified\name\of\the\second\indicator',
853
     *          ],
854
     *          'timesplitting' => '\optional\name\of\the\time_splitting\class',
855
     *          'enabled' => true,
856
     *      ],
857
     *  ];
858
     *
859
     * @param array $models List of declared models.
860
     * @throws \coding_exception Exception thrown in case of invalid syntax.
861
     */
862
    public static function validate_models_declaration(array $models) {
863
 
864
        foreach ($models as $model) {
865
            if (!isset($model['target'])) {
866
                throw new \coding_exception('Missing target declaration');
867
            }
868
 
869
            if (!static::is_valid($model['target'], '\core_analytics\local\target\base')) {
870
                throw new \coding_exception('Invalid target classname', $model['target']);
871
            }
872
 
873
            if (empty($model['indicators']) || !is_array($model['indicators'])) {
874
                throw new \coding_exception('Missing indicators declaration');
875
            }
876
 
877
            foreach ($model['indicators'] as $indicator) {
878
                if (!static::is_valid($indicator, '\core_analytics\local\indicator\base')) {
879
                    throw new \coding_exception('Invalid indicator classname', $indicator);
880
                }
881
            }
882
 
883
            if (isset($model['timesplitting'])) {
884
                if (substr($model['timesplitting'], 0, 1) !== '\\') {
885
                    throw new \coding_exception('Expecting fully qualified time splitting classname', $model['timesplitting']);
886
                }
887
                if (!static::is_valid($model['timesplitting'], '\core_analytics\local\time_splitting\base')) {
888
                    throw new \coding_exception('Invalid time splitting classname', $model['timesplitting']);
889
                }
890
            }
891
 
892
            if (!empty($model['enabled']) && !isset($model['timesplitting'])) {
893
                throw new \coding_exception('Cannot enable a model without time splitting method specified');
894
            }
895
        }
896
    }
897
 
898
    /**
899
     * Create the defined model.
900
     *
901
     * @param array $definition See {@link self::validate_models_declaration()} for the syntax.
902
     * @return \core_analytics\model
903
     */
904
    public static function create_declared_model(array $definition): \core_analytics\model {
905
 
906
        list($target, $indicators) = static::get_declared_target_and_indicators_instances($definition);
907
 
908
        if (isset($definition['timesplitting'])) {
909
            $timesplitting = $definition['timesplitting'];
910
        } else {
911
            $timesplitting = false;
912
        }
913
 
914
        $created = \core_analytics\model::create($target, $indicators, $timesplitting);
915
 
916
        if (!empty($definition['enabled'])) {
917
            $created->enable();
918
        }
919
 
920
        return $created;
921
    }
922
 
923
    /**
924
     * Returns a string uniquely representing the given model declaration.
925
     *
926
     * @param array $model Model declaration
927
     * @return string complying with PARAM_ALPHANUM rules and starting with an 'id' prefix
928
     */
929
    public static function model_declaration_identifier(array $model): string {
930
        return 'id'.sha1(serialize($model));
931
    }
932
 
933
    /**
934
     * Given a model definition, return actual target and indicators instances.
935
     *
936
     * @param array $definition See {@link self::validate_models_declaration()} for the syntax.
937
     * @return array [0] => target instance, [1] => array of indicators instances
938
     */
939
    public static function get_declared_target_and_indicators_instances(array $definition): array {
940
 
941
        $target = static::get_target($definition['target']);
942
 
943
        $indicators = [];
944
 
945
        foreach ($definition['indicators'] as $indicatorname) {
946
            $indicator = static::get_indicator($indicatorname);
947
            $indicators[$indicator->get_id()] = $indicator;
948
        }
949
 
950
        return [$target, $indicators];
951
    }
952
 
953
    /**
954
     * Return the context restrictions that can be applied to the provided context levels.
955
     *
956
     * @throws \coding_exception
957
     * @param  array|null $contextlevels The list of context levels provided by the analyser. Null if all of them.
958
     * @param  string|null $query
959
     * @return array Associative array with contextid as key and the short version of the context name as value.
960
     */
1441 ariadna 961
    public static function get_potential_context_restrictions(?array $contextlevels = null, ?string $query = null) {
1 efrain 962
        global $DB;
963
 
964
        if (empty($contextlevels) && !is_null($contextlevels)) {
965
            return false;
966
        }
967
 
968
        if (!is_null($contextlevels)) {
969
            foreach ($contextlevels as $contextlevel) {
970
                if ($contextlevel !== CONTEXT_COURSE && $contextlevel !== CONTEXT_COURSECAT) {
971
                    throw new \coding_exception('Only CONTEXT_COURSE and CONTEXT_COURSECAT are supported at the moment.');
972
                }
973
            }
974
        }
975
 
976
        $contexts = [];
977
 
978
        // We have a separate process for each context level for performance reasons (to iterate through mdl_context calling
979
        // get_context_name() would be too slow).
980
        $contextsystem = \context_system::instance();
981
        if (is_null($contextlevels) || in_array(CONTEXT_COURSECAT, $contextlevels)) {
982
 
983
            $sql = "SELECT cc.id, cc.name, ctx.id AS contextid
984
                      FROM {course_categories} cc
985
                      JOIN {context} ctx ON ctx.contextlevel = :ctxlevel AND ctx.instanceid = cc.id";
986
            $params = ['ctxlevel' => CONTEXT_COURSECAT];
987
 
988
            if ($query) {
989
                $sql .= " WHERE " . $DB->sql_like('cc.name', ':query', false, false);
990
                $params['query'] = '%' . $query . '%';
991
            }
992
 
993
            $coursecats = $DB->get_recordset_sql($sql, $params);
994
            foreach ($coursecats as $record) {
995
                $contexts[$record->contextid] = get_string('category') . ': ' .
996
                    format_string($record->name, true, array('context' => $contextsystem));
997
            }
998
            $coursecats->close();
999
        }
1000
 
1001
        if (is_null($contextlevels) || in_array(CONTEXT_COURSE, $contextlevels)) {
1002
 
1003
            $sql = "SELECT c.id, c.shortname, ctx.id AS contextid
1004
                      FROM {course} c
1005
                      JOIN {context} ctx ON ctx.contextlevel = :ctxlevel AND ctx.instanceid = c.id
1006
                      WHERE c.id != :siteid";
1007
            $params = ['ctxlevel' => CONTEXT_COURSE, 'siteid' => SITEID];
1008
 
1009
            if ($query) {
1010
                $sql .= ' AND (' . $DB->sql_like('c.fullname', ':query1', false, false) . ' OR ' .
1011
                    $DB->sql_like('c.shortname', ':query2', false, false) . ')';
1012
                $params['query1'] = '%' . $query . '%';
1013
                $params['query2'] = '%' . $query . '%';
1014
            }
1015
 
1016
            $courses = $DB->get_recordset_sql($sql, $params);
1017
            foreach ($courses as $record) {
1018
                $contexts[$record->contextid] = get_string('course') . ': ' .
1019
                    format_string($record->shortname, true, array('context' => $contextsystem));
1020
            }
1021
            $courses->close();
1022
        }
1023
 
1024
        return $contexts;
1025
    }
1026
 
1027
}