6ea0d22673f63781b23a24644e9d1f128640da78
[moodle.git] / analytics / classes / local / analyser / base.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Analysers base class.
19  *
20  * @package   core_analytics
21  * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
22  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace core_analytics\local\analyser;
27 defined('MOODLE_INTERNAL') || die();
29 /**
30  * Analysers base class.
31  *
32  * @package   core_analytics
33  * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
34  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35  */
36 abstract class base {
38     /**
39      * @var int
40      */
41     protected $modelid;
43     /**
44      * The model target.
45      *
46      * @var \core_analytics\local\target\base
47      */
48     protected $target;
50     /**
51      * The model indicators.
52      *
53      * @var \core_analytics\local\indicator\base[]
54      */
55     protected $indicators;
57     /**
58      * Time splitting methods to use.
59      *
60      * Multiple time splitting methods during evaluation and 1 single
61      * time splitting method once the model is enabled.
62      *
63      * @var \core_analytics\local\time_splitting\base[]
64      */
65     protected $timesplittings;
67     /**
68      * Execution options.
69      *
70      * @var array
71      */
72     protected $options;
74     /**
75      * Simple log array.
76      *
77      * @var string[]
78      */
79     protected $log;
81     /**
82      * Constructor method.
83      *
84      * @param int $modelid
85      * @param \core_analytics\local\target\base $target
86      * @param \core_analytics\local\indicator\base[] $indicators
87      * @param \core_analytics\local\time_splitting\base[] $timesplittings
88      * @param array $options
89      * @return void
90      */
91     public function __construct($modelid, \core_analytics\local\target\base $target, $indicators, $timesplittings, $options) {
92         $this->modelid = $modelid;
93         $this->target = $target;
94         $this->indicators = $indicators;
95         $this->timesplittings = $timesplittings;
97         if (empty($options['evaluation'])) {
98             $options['evaluation'] = false;
99         }
100         $this->options = $options;
102         // Checks if the analyser satisfies the indicators requirements.
103         $this->check_indicators_requirements();
105         $this->log = array();
106     }
108     /**
109      * Returns the list of analysable elements available on the site.
110      *
111      * \core_analytics\local\analyser\by_course and \core_analytics\local\analyser\sitewide are implementing
112      * this method returning site courses (by_course) and the whole system (sitewide) as analysables.
113      *
114      * @todo MDL-65284 This will be removed in Moodle 4.1
115      * @deprecated
116      * @see get_analysables_iterator
117      * @throws  \coding_exception
118      * @return \core_analytics\analysable[] Array of analysable elements using the analysable id as array key.
119      */
120     public function get_analysables() {
121         // This function should only be called from get_analysables_iterator and we keep it here until Moodle 4.1
122         // for backwards compatibility.
123         throw new \coding_exception('This method is deprecated in favour of get_analysables_iterator.');
124     }
126     /**
127      * Returns the list of analysable elements available on the site.
128      *
129      * A relatively complex SQL query should be set so that we take into account which analysable elements
130      * have already been processed and the order in which they have been processed. Helper methods are available
131      * to ease to implementation of get_analysables_iterator: get_iterator_sql and order_sql.
132      *
133      * @param string|null $action 'prediction', 'training' or null if no specific action needed.
134      * @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty.
135      * @return \Iterator
136      */
137     public function get_analysables_iterator(?string $action = null, array $contexts = []) {
139         debugging('Please overwrite get_analysables_iterator with your own implementation, we only keep this default
140             implementation for backwards compatibility purposes with get_analysables(). note that $action param will
141             be ignored so the analysable elements will be processed using get_analysables order, regardless of the
142             last time they were processed.');
144         return new \ArrayIterator($this->get_analysables());
145     }
147     /**
148      * This function returns this analysable list of samples.
149      *
150      * @param \core_analytics\analysable $analysable
151      * @return array array[0] = int[] (sampleids) and array[1] = array (samplesdata)
152      */
153     abstract public function get_all_samples(\core_analytics\analysable $analysable);
155     /**
156      * This function returns the samples data from a list of sample ids.
157      *
158      * @param int[] $sampleids
159      * @return array array[0] = int[] (sampleids) and array[1] = array (samplesdata)
160      */
161     abstract public function get_samples($sampleids);
163     /**
164      * Returns the analysable of a sample.
165      *
166      * @param int $sampleid
167      * @return \core_analytics\analysable
168      */
169     abstract public function get_sample_analysable($sampleid);
171     /**
172      * Returns the sample's origin in moodle database.
173      *
174      * @return string
175      */
176     abstract public function get_samples_origin();
178     /**
179      * Returns the context of a sample.
180      *
181      * moodle/analytics:listinsights will be required at this level to access the sample predictions.
182      *
183      * @param int $sampleid
184      * @return \context
185      */
186     abstract public function sample_access_context($sampleid);
188     /**
189      * Describes a sample with a description summary and a \renderable (an image for example)
190      *
191      * @param int $sampleid
192      * @param int $contextid
193      * @param array $sampledata
194      * @return array array(string, \renderable)
195      */
196     abstract public function sample_description($sampleid, $contextid, $sampledata);
198     /**
199      * Model id getter.
200      * @return int
201      */
202     public function get_modelid(): int {
203         return $this->modelid;
204     }
206     /**
207      * Options getter.
208      * @return array
209      */
210     public function get_options(): array {
211         return $this->options;
212     }
214     /**
215      * Returns the analysed target.
216      *
217      * @return \core_analytics\local\target\base
218      */
219     public function get_target(): \core_analytics\local\target\base {
220         return $this->target;
221     }
223     /**
224      * Getter for time splittings.
225      *
226      * @return \core_analytics\local\time_splitting\base
227      */
228     public function get_timesplittings(): array {
229         return $this->timesplittings;
230     }
232     /**
233      * Getter for indicators.
234      *
235      * @return \core_analytics\local\indicator\base
236      */
237     public function get_indicators(): array {
238         return $this->indicators;
239     }
241     /**
242      * Instantiate the indicators.
243      *
244      * @return \core_analytics\local\indicator\base[]
245      */
246     public function instantiate_indicators() {
247         foreach ($this->indicators as $key => $indicator) {
248             $this->indicators[$key] = call_user_func(array($indicator, 'instance'));
249         }
251         // Free memory ASAP.
252         gc_collect_cycles();
253         gc_mem_caches();
255         return $this->indicators;
256     }
258     /**
259      * Samples data this analyser provides.
260      *
261      * @return string[]
262      */
263     protected function provided_sample_data() {
264         return array($this->get_samples_origin());
265     }
267     /**
268      * Returns labelled data (training and evaluation).
269      *
270      * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
271      * @return \stored_file[]
272      */
273     public function get_labelled_data(array $contexts = []) {
274         // Delegates all processing to the analysis.
275         $result = new \core_analytics\local\analysis\result_file($this->get_modelid(), true, $this->get_options());
276         $analysis = new \core_analytics\analysis($this, true, $result);
277         $analysis->run($contexts);
278         return $result->get();
279     }
281     /**
282      * Returns unlabelled data (prediction).
283      *
284      * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
285      * @return \stored_file[]
286      */
287     public function get_unlabelled_data(array $contexts = []) {
288         // Delegates all processing to the analysis.
289         $result = new \core_analytics\local\analysis\result_file($this->get_modelid(), false, $this->get_options());
290         $analysis = new \core_analytics\analysis($this, false, $result);
291         $analysis->run($contexts);
292         return $result->get();
293     }
295     /**
296      * Returns indicator calculations as an array.
297      *
298      * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
299      * @return array
300      */
301     public function get_static_data(array $contexts = []) {
302         // Delegates all processing to the analysis.
303         $result = new \core_analytics\local\analysis\result_array($this->get_modelid(), false, $this->get_options());
304         $analysis = new \core_analytics\analysis($this, false, $result);
305         $analysis->run($contexts);
306         return $result->get();
307     }
309     /**
310      * Checks if the analyser satisfies all the model indicators requirements.
311      *
312      * @throws \core_analytics\requirements_exception
313      * @return void
314      */
315     protected function check_indicators_requirements() {
317         foreach ($this->indicators as $indicator) {
318             $missingrequired = $this->check_indicator_requirements($indicator);
319             if ($missingrequired !== true) {
320                 throw new \core_analytics\requirements_exception(get_class($indicator) . ' indicator requires ' .
321                     json_encode($missingrequired) . ' sample data which is not provided by ' . get_class($this));
322             }
323         }
324     }
326     /**
327      * Checks that this analyser satisfies the provided indicator requirements.
328      *
329      * @param \core_analytics\local\indicator\base $indicator
330      * @return true|string[] True if all good, missing requirements list otherwise
331      */
332     public function check_indicator_requirements(\core_analytics\local\indicator\base $indicator) {
334         $providedsampledata = $this->provided_sample_data();
336         $requiredsampledata = $indicator::required_sample_data();
337         if (empty($requiredsampledata)) {
338             // The indicator does not need any sample data.
339             return true;
340         }
341         $missingrequired = array_diff($requiredsampledata, $providedsampledata);
343         if (empty($missingrequired)) {
344             return true;
345         }
347         return $missingrequired;
348     }
350     /**
351      * Adds a register to the analysis log.
352      *
353      * @param string $string
354      * @return void
355      */
356     public function add_log($string) {
357         $this->log[] = $string;
358     }
360     /**
361      * Returns the analysis logs.
362      *
363      * @return string[]
364      */
365     public function get_logs() {
366         return $this->log;
367     }
369     /**
370      * Whether the plugin needs user data clearing or not.
371      *
372      * This is related to privacy. Override this method if your analyser samples have any relation
373      * to the 'user' database entity. We need to clean the site from all user-related data if a user
374      * request their data to be deleted from the system. A static::provided_sample_data returning 'user'
375      * is an indicator that you should be returning true.
376      *
377      * @return bool
378      */
379     public function processes_user_data() {
380         return false;
381     }
383     /**
384      * SQL JOIN from a sample to users table.
385      *
386      * This function should be defined if static::processes_user_data returns true and it is related to analytics API
387      * privacy API implementation. It allows the analytics API to identify data associated to users that needs to be
388      * deleted or exported.
389      *
390      * This function receives the alias of a table with a 'sampleid' field and it should return a SQL join
391      * with static::get_samples_origin and with 'user' table. Note that:
392      * - The function caller expects the returned 'user' table to be aliased as 'u' (defacto standard in moodle).
393      * - You can join with other tables if your samples origin table does not contain a 'userid' field (if that would be
394      *   a requirement this solution would be automated for you) you can't though use the following
395      *   aliases: 'ap', 'apa', 'aic' and 'am'.
396      *
397      * Some examples:
398      *
399      * static::get_samples_origin() === 'user':
400      *   JOIN {user} u ON {$sampletablealias}.sampleid = u.id
401      *
402      * static::get_samples_origin() === 'role_assignments':
403      *   JOIN {role_assignments} ra ON {$sampletablealias}.sampleid = ra.userid JOIN {user} u ON u.id = ra.userid
404      *
405      * static::get_samples_origin() === 'user_enrolments':
406      *   JOIN {user_enrolments} ue ON {$sampletablealias}.sampleid = ue.userid JOIN {user} u ON u.id = ue.userid
407      *
408      * @throws \coding_exception
409      * @param string $sampletablealias The alias of the table with a sampleid field that will join with this SQL string
410      * @return string
411      */
412     public function join_sample_user($sampletablealias) {
413         throw new \coding_exception('This method should be implemented if static::processes_user_data returns true.');
414     }
416     /**
417      * Do this analyser's analysables have 1 single sample each?
418      *
419      * Overwrite and return true if your analysables only have
420      * one sample. The insights generated by models using this
421      * analyser will then include the suggested actions in the
422      * notification.
423      *
424      * @return bool
425      */
426     public static function one_sample_per_analysable() {
427         return false;
428     }
430     /**
431      * Returns an array of context levels that can be used to restrict the contexts used during analysis.
432      *
433      * The contexts provided to self::get_analysables_iterator will match these contextlevels.
434      *
435      * @return array Array of context levels or an empty array if context restriction is not supported.
436      */
437     public static function context_restriction_support(): array {
438         return [];
439     }
441     /**
442      * Returns the possible contexts used by the analyser.
443      *
444      * This method uses separate logic for each context level because to iterate through
445      * the list of contexts calling get_context_name for each of them would be expensive
446      * in performance terms.
447      *
448      * This generic implementation returns all the contexts in the site for the provided context level.
449      * Overwrite it for specific restrictions in your analyser.
450      *
451      * @return int[]
452      */
453     public static function potential_context_restrictions() {
454         return \core_analytics\manager::get_potential_context_restrictions(static::context_restriction_support());
455     }
457     /**
458      * Get the sql of a default implementation of the iterator.
459      *
460      * This method only works for analysers that return analysable elements which ids map to a context instance ids.
461      *
462      * @param  string      $tablename    The name of the table
463      * @param  int         $contextlevel The context level of the analysable
464      * @param  string|null $action
465      * @param  string|null $tablealias   The table alias
466      * @param  \context[]  $contexts     Only analysables that depend on the provided contexts. All analysables if empty.
467      * @return array                     [0] => sql and [1] => params array
468      */
469     protected function get_iterator_sql(string $tablename, int $contextlevel, ?string $action = null, ?string $tablealias = null,
470             array $contexts = []) {
471         global $DB;
473         if (!$tablealias) {
474             $tablealias = 'analysable';
475         }
477         $params = ['contextlevel' => $contextlevel, 'modelid' => $this->get_modelid()];
478         $select = $tablealias . '.*, ' . \context_helper::get_preload_record_columns_sql('ctx');
480         // We add the action filter on ON instead of on WHERE because otherwise records are not returned if there are existing
481         // records for another action or model.
482         $usedanalysablesjoin = ' LEFT JOIN {analytics_used_analysables} aua ON ' . $tablealias . '.id = aua.analysableid AND ' .
483             '(aua.modelid = :modelid OR aua.modelid IS NULL)';
485         if ($action) {
486             $usedanalysablesjoin .= " AND aua.action = :action";
487             $params = $params + ['action' => $action];
488         }
490         $sql = 'SELECT ' . $select . '
491                   FROM {' . $tablename . '} ' . $tablealias . '
492                   ' . $usedanalysablesjoin . '
493                   JOIN {context} ctx ON (ctx.contextlevel = :contextlevel AND ctx.instanceid = ' . $tablealias . '.id) ';
495         if (!$contexts) {
496             // Adding the 1 = 1 just to have the WHERE part so that all further conditions
497             // added by callers can be appended to $sql with and ' AND'.
498             $sql .= 'WHERE 1 = 1';
499         } else {
501             $contextsqls = [];
502             foreach ($contexts as $context) {
503                 $paramkey1 = 'paramctxlike' . $context->id;
504                 $paramkey2 = 'paramctxeq' . $context->id;
505                 $contextsqls[] = $DB->sql_like('ctx.path', ':' . $paramkey1);
506                 $contextsqls[] = 'ctx.path = :' . $paramkey2;
508                 // This includes the context itself.
509                 $params[$paramkey1] = $context->path . '/%';
510                 $params[$paramkey2] = $context->path;
511             }
512             $sql .= 'WHERE (' . implode(' OR ', $contextsqls) . ')';
513         }
515         return [$sql, $params];
516     }
518     /**
519      * Returns the order by clause.
520      *
521      * @param  string|null $fieldname  The field name
522      * @param  string      $order      'ASC' or 'DESC'
523      * @param  string|null $tablealias The table alias of the field
524      * @return string
525      */
526     protected function order_sql(?string $fieldname = null, string $order = 'ASC', ?string $tablealias = null) {
528         if (!$tablealias) {
529             $tablealias = 'analysable';
530         }
532         if ($order != 'ASC' && $order != 'DESC') {
533             throw new \coding_exception('The order can only be ASC or DESC');
534         }
536         $ordersql = ' ORDER BY (CASE WHEN aua.timeanalysed IS NULL THEN 0 ELSE aua.timeanalysed END) ASC';
537         if ($fieldname) {
538             $ordersql .= ', ' . $tablealias . '.' . $fieldname .' ' . $order;
539         }
541         return $ordersql;
542     }