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 * Abstract base target.
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 */
24
25namespace core_analytics\local\target;
26
27defined('MOODLE_INTERNAL') || die();
28
29/**
30 * Abstract base target.
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 */
36abstract class base extends \core_analytics\calculable {
37
38    /**
39     * This target have linear or discrete values.
40     *
41     * @return bool
42     */
43    abstract public function is_linear();
44
45    /**
46     * Returns the analyser class that should be used along with this target.
47     *
48     * @return string The full class name as a string
49     */
50    abstract public function get_analyser_class();
51
52    /**
53     * Allows the target to verify that the analysable is a good candidate.
54     *
55     * This method can be used as a quick way to discard invalid analysables.
56     * e.g. Imagine that your analysable don't have students and you need them.
57     *
58     * @param \core_analytics\analysable $analysable
59     * @param bool $fortraining
60     * @return true|string
61     */
62    abstract public function is_valid_analysable(\core_analytics\analysable $analysable, $fortraining = true);
63
64    /**
65     * Is this sample from the $analysable valid?
66     *
67     * @param int $sampleid
68     * @param \core_analytics\analysable $analysable
69     * @param bool $fortraining
70     * @return bool
71     */
72    abstract public function is_valid_sample($sampleid, \core_analytics\analysable $analysable, $fortraining = true);
73
74    /**
75     * Calculates this target for the provided samples.
76     *
77     * In case there are no values to return or the provided sample is not applicable just return null.
78     *
79     * @param int $sampleid
80     * @param \core_analytics\analysable $analysable
81     * @param int|false $starttime Limit calculations to start time
82     * @param int|false $endtime Limit calculations to end time
83     * @return float|null
84     */
85    abstract protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false);
86
87    /**
88     * Can the provided time-splitting method be used on this target?.
89     *
90     * Time-splitting methods not matching the target requirements will not be selectable by models based on this target.
91     *
92     * @param  \core_analytics\local\time_splitting\base $timesplitting
93     * @return bool
94     */
95    abstract public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool;
96
97    /**
98     * Is this target generating insights?
99     *
100     * Defaults to true.
101     *
102     * @return bool
103     */
104    public static function uses_insights() {
105        return true;
106    }
107
108    /**
109     * Should the insights of this model be linked from reports?
110     *
111     * @return bool
112     */
113    public function link_insights_report(): bool {
114        return true;
115    }
116
117    /**
118     * Based on facts (processed by machine learning backends) by default.
119     *
120     * @return bool
121     */
122    public static function based_on_assumptions() {
123        return false;
124    }
125
126    /**
127     * Update the last analysis time on analysable processed or always.
128     *
129     * If you overwrite this method to return false the last analysis time
130     * will only be recorded in DB when the element successfully analysed. You can
131     * safely return false for lightweight targets.
132     *
133     * @return bool
134     */
135    public function always_update_analysis_time(): bool {
136        return true;
137    }
138
139    /**
140     * Suggested actions for a user.
141     *
142     * @param \core_analytics\prediction $prediction
143     * @param bool $includedetailsaction
144     * @param bool $isinsightuser                       Force all the available actions to be returned as it the user who
145     *                                                  receives the insight is the one logged in.
146     * @return \core_analytics\prediction_action[]
147     */
148    public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false,
149            $isinsightuser = false) {
150        global $PAGE;
151
152        $predictionid = $prediction->get_prediction_data()->id;
153        $contextid = $prediction->get_prediction_data()->contextid;
154        $modelid = $prediction->get_prediction_data()->modelid;
155
156        $actions = array();
157
158        if ($this->link_insights_report() && $includedetailsaction) {
159
160            $predictionurl = new \moodle_url('/report/insights/prediction.php', array('id' => $predictionid));
161            $detailstext = $this->get_view_details_text();
162
163            $actions[] = new \core_analytics\prediction_action(\core_analytics\prediction::ACTION_PREDICTION_DETAILS, $prediction,
164                $predictionurl, new \pix_icon('t/preview', $detailstext),
165                $detailstext, false, [], \core_analytics\action::TYPE_NEUTRAL);
166        }
167
168        return $actions;
169    }
170
171    /**
172     * Suggested bulk actions for a user.
173     *
174     * @param  \core_analytics\prediction[]     $predictions List of predictions suitable for the bulk actions to use.
175     * @return \core_analytics\bulk_action[]                 The list of bulk actions.
176     */
177    public function bulk_actions(array $predictions) {
178
179        $analyserclass = $this->get_analyser_class();
180        if ($analyserclass::one_sample_per_analysable()) {
181            // Default actions are useful / not useful.
182            $actions = [
183                \core_analytics\default_bulk_actions::useful(),
184                \core_analytics\default_bulk_actions::not_useful()
185            ];
186
187        } else {
188            // Accept and not applicable.
189
190            $actions = [
191                \core_analytics\default_bulk_actions::accept(),
192                \core_analytics\default_bulk_actions::not_applicable()
193            ];
194
195            if (!self::based_on_assumptions()) {
196                // We include incorrectly flagged.
197                $actions[] = \core_analytics\default_bulk_actions::incorrectly_flagged();
198            }
199        }
200
201        return $actions;
202    }
203
204    /**
205     * Adds the JS required to run the bulk actions.
206     */
207    public function add_bulk_actions_js() {
208        global $PAGE;
209        $PAGE->requires->js_call_amd('report_insights/actions', 'initBulk', ['.insights-bulk-actions']);
210    }
211
212    /**
213     * Returns the view details link text.
214     * @return string
215     */
216    private function get_view_details_text() {
217        if ($this->based_on_assumptions()) {
218            $analyserclass = $this->get_analyser_class();
219            if ($analyserclass::one_sample_per_analysable()) {
220                $detailstext = get_string('viewinsightdetails', 'analytics');
221            } else {
222                $detailstext = get_string('viewdetails', 'analytics');
223            }
224        } else {
225            $detailstext = get_string('viewprediction', 'analytics');
226        }
227
228        return $detailstext;
229    }
230
231    /**
232     * Callback to execute once a prediction has been returned from the predictions processor.
233     *
234     * Note that the analytics_predictions db record is not yet inserted.
235     *
236     * @param int $modelid
237     * @param int $sampleid
238     * @param int $rangeindex
239     * @param \context $samplecontext
240     * @param float|int $prediction
241     * @param float $predictionscore
242     * @return void
243     */
244    public function prediction_callback($modelid, $sampleid, $rangeindex, \context $samplecontext, $prediction, $predictionscore) {
245        return;
246    }
247
248    /**
249     * Generates insights notifications
250     *
251     * @param int $modelid
252     * @param \context[] $samplecontexts
253     * @param  \core_analytics\prediction[] $predictions
254     * @return void
255     */
256    public function generate_insight_notifications($modelid, $samplecontexts, array $predictions = []) {
257        // Delegate the processing of insights to the insights_generator.
258        $insightsgenerator = new \core_analytics\insights_generator($modelid, $this);
259        $insightsgenerator->generate($samplecontexts, $predictions);
260    }
261
262    /**
263     * Returns the list of users that will receive insights notifications.
264     *
265     * Feel free to overwrite if you need to but keep in mind that moodle/analytics:listinsights
266     * or moodle/analytics:listowninsights capability is required to access the list of insights.
267     *
268     * @param \context $context
269     * @return array
270     */
271    public function get_insights_users(\context $context) {
272        if ($context->contextlevel === CONTEXT_USER) {
273            if (!has_capability('moodle/analytics:listowninsights', $context, $context->instanceid)) {
274                $users = [];
275            } else {
276                $users = [$context->instanceid => \core_user::get_user($context->instanceid)];
277            }
278
279        } else if ($context->contextlevel >= CONTEXT_COURSE) {
280            // At course level or below only enrolled users although this is not ideal for
281            // teachers assigned at category level.
282            $users = get_enrolled_users($context, 'moodle/analytics:listinsights', 0, 'u.*', null, 0, 0, true);
283        } else {
284            $users = get_users_by_capability($context, 'moodle/analytics:listinsights');
285        }
286        return $users;
287    }
288
289    /**
290     * URL to the insight.
291     *
292     * @param  int $modelid
293     * @param  \context $context
294     * @return \moodle_url
295     */
296    public function get_insight_context_url($modelid, $context) {
297        return new \moodle_url('/report/insights/insights.php?modelid=' . $modelid . '&contextid=' . $context->id);
298    }
299
300    /**
301     * The insight notification subject.
302     *
303     * This is just a default message, you should overwrite it for a custom insight message.
304     *
305     * @param  int $modelid
306     * @param  \context $context
307     * @return string
308     */
309    public function get_insight_subject(int $modelid, \context $context) {
310        return get_string('insightmessagesubject', 'analytics', $context->get_context_name());
311    }
312
313    /**
314     * Returns the body message for an insight with multiple predictions.
315     *
316     * This default method is executed when the analysable used by the model generates multiple insight
317     * for each analysable (one_sample_per_analysable === false)
318     *
319     * @param  \context     $context
320     * @param  string       $contextname
321     * @param  \stdClass    $user
322     * @param  \moodle_url  $insighturl
323     * @return string[]                     The plain text message and the HTML message
324     */
325    public function get_insight_body(\context $context, string $contextname, \stdClass $user, \moodle_url $insighturl): array {
326        global $OUTPUT;
327
328        $fullmessage = get_string('insightinfomessageplain', 'analytics', $insighturl->out(false));
329        $fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message',
330            ['url' => $insighturl->out(false), 'insightinfomessage' => get_string('insightinfomessagehtml', 'analytics')]
331        );
332
333        return [$fullmessage, $fullmessagehtml];
334    }
335
336    /**
337     * Returns the body message for an insight for a single prediction.
338     *
339     * This default method is executed when the analysable used by the model generates one insight
340     * for each analysable (one_sample_per_analysable === true)
341     *
342     * @param  \context                             $context
343     * @param  \stdClass                            $user
344     * @param  \core_analytics\prediction           $prediction
345     * @param  \core_analytics\action[]             $actions        Passed by reference to remove duplicate links to actions.
346     * @return array                                                Plain text msg, HTML message and the main URL for this
347     *                                                              insight (you can return null if you are happy with the
348     *                                                              default insight URL calculated in prediction_info())
349     */
350    public function get_insight_body_for_prediction(\context $context, \stdClass $user, \core_analytics\prediction $prediction,
351            array &$actions) {
352        // No extra message by default.
353        return [FORMAT_PLAIN => '', FORMAT_HTML => '', 'url' => null];
354    }
355
356    /**
357     * Returns an instance of the child class.
358     *
359     * Useful to reset cached data.
360     *
361     * @return \core_analytics\base\target
362     */
363    public static function instance() {
364        return new static();
365    }
366
367    /**
368     * Defines a boundary to ignore predictions below the specified prediction score.
369     *
370     * Value should go from 0 to 1.
371     *
372     * @return float
373     */
374    protected function min_prediction_score() {
375        // The default minimum discards predictions with a low score.
376        return \core_analytics\model::PREDICTION_MIN_SCORE;
377    }
378
379    /**
380     * This method determines if a prediction is interesing for the model or not.
381     *
382     * @param mixed $predictedvalue
383     * @param float $predictionscore
384     * @return bool
385     */
386    public function triggers_callback($predictedvalue, $predictionscore) {
387
388        $minscore = floatval($this->min_prediction_score());
389        if ($minscore < 0) {
390            debugging(get_class($this) . ' minimum prediction score is below 0, please update it to a value between 0 and 1.');
391        } else if ($minscore > 1) {
392            debugging(get_class($this) . ' minimum prediction score is above 1, please update it to a value between 0 and 1.');
393        }
394
395        // We need to consider that targets may not have a min score.
396        if (!empty($minscore) && floatval($predictionscore) < $minscore) {
397            return false;
398        }
399
400        return true;
401    }
402
403    /**
404     * Calculates the target.
405     *
406     * Returns an array of values which size matches $sampleids size.
407     *
408     * Rows with null values will be skipped as invalid by time splitting methods.
409     *
410     * @param array $sampleids
411     * @param \core_analytics\analysable $analysable
412     * @param int $starttime
413     * @param int $endtime
414     * @return array The format to follow is [userid] = scalar|null
415     */
416    public function calculate($sampleids, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) {
417
418        if (!PHPUNIT_TEST && CLI_SCRIPT) {
419            echo '.';
420        }
421
422        $calculations = [];
423        foreach ($sampleids as $sampleid => $unusedsampleid) {
424
425            // No time limits when calculating the target to train models.
426            $calculatedvalue = $this->calculate_sample($sampleid, $analysable, $starttime, $endtime);
427
428            if (!is_null($calculatedvalue)) {
429                if ($this->is_linear() &&
430                        ($calculatedvalue > static::get_max_value() || $calculatedvalue < static::get_min_value())) {
431                    throw new \coding_exception('Calculated values should be higher than ' . static::get_min_value() .
432                        ' and lower than ' . static::get_max_value() . '. ' . $calculatedvalue . ' received');
433                } else if (!$this->is_linear() && static::is_a_class($calculatedvalue) === false) {
434                    throw new \coding_exception('Calculated values should be one of the target classes (' .
435                        json_encode(static::get_classes()) . '). ' . $calculatedvalue . ' received');
436                }
437            }
438            $calculations[$sampleid] = $calculatedvalue;
439        }
440        return $calculations;
441    }
442
443    /**
444     * Filters out invalid samples for training.
445     *
446     * @param int[] $sampleids
447     * @param \core_analytics\analysable $analysable
448     * @param bool $fortraining
449     * @return void
450     */
451    public function filter_out_invalid_samples(&$sampleids, \core_analytics\analysable $analysable, $fortraining = true) {
452        foreach ($sampleids as $sampleid => $unusedsampleid) {
453            if (!$this->is_valid_sample($sampleid, $analysable, $fortraining)) {
454                // Skip it and remove the sample from the list of calculated samples.
455                unset($sampleids[$sampleid]);
456            }
457        }
458    }
459}
460