MDL-58835 analytics: Store prediction actions separately
authorDavid Monllao <davidm@moodle.com>
Mon, 28 Aug 2017 10:36:54 +0000 (12:36 +0200)
committerDavid Monllao <davidm@moodle.com>
Thu, 7 Sep 2017 12:45:34 +0000 (14:45 +0200)
New event for insights viewed as part of this issue.

12 files changed:
analytics/classes/local/target/base.php
analytics/classes/model.php
analytics/classes/prediction.php
analytics/classes/prediction_action.php
lang/en/analytics.php
lib/classes/analytics/target/course_dropout.php
lib/classes/analytics/target/no_teaching.php
lib/classes/event/insights_viewed.php [new file with mode: 0644]
lib/db/install.xml
lib/db/upgrade.php
report/insights/insights.php
version.php

index 1a4f978..fdbf52f 100644 (file)
@@ -112,18 +112,42 @@ abstract class base extends \core_analytics\calculable {
      * @return \core_analytics\prediction_action[]
      */
     public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false) {
+        global $PAGE;
+
+        $PAGE->requires->js_call_amd('report_insights/actions', 'init');
+
         $actions = array();
 
+        $predictionid = $prediction->get_prediction_data()->id;
+
         if ($includedetailsaction) {
 
             $predictionurl = new \moodle_url('/report/insights/prediction.php',
-                array('id' => $prediction->get_prediction_data()->id));
+                array('id' => $predictionid));
 
-            $actions['predictiondetails'] = new \core_analytics\prediction_action('predictiondetails', $prediction,
+            $actions[] = new \core_analytics\prediction_action(\core_analytics\prediction::ACTION_PREDICTION_DETAILS, $prediction,
                 $predictionurl, new \pix_icon('t/preview', get_string('viewprediction', 'analytics')),
                 get_string('viewprediction', 'analytics'));
         }
 
+        // Flag as not useful.
+        $notusefulattrs = array(
+            'data-prediction-id' => $predictionid,
+            'data-prediction-methodname' => 'report_insights_set_notuseful_prediction'
+        );
+        $actions[] = new \core_analytics\prediction_action(\core_analytics\prediction::ACTION_NOT_USEFUL,
+            $prediction, new \moodle_url(''), new \pix_icon('t/delete', get_string('notuseful', 'analytics')),
+            get_string('notuseful', 'analytics'), false, $notusefulattrs);
+
+        // Flag as fixed / solved.
+        $fixedattrs = array(
+            'data-prediction-id' => $predictionid,
+            'data-prediction-methodname' => 'report_insights_set_fixed_prediction'
+        );
+        $actions[] = new \core_analytics\prediction_action(\core_analytics\prediction::ACTION_FIXED,
+            $prediction, new \moodle_url(''), new \pix_icon('t/check', get_string('fixedack', 'analytics')),
+            get_string('fixedack', 'analytics'), false, $fixedattrs);
+
         return $actions;
     }
 
index 8120dd6..0333057 100644 (file)
@@ -1100,8 +1100,8 @@ class model {
      * @param int $perpage The max number of results to fetch. Ignored if $page is false.
      * @return array($total, \core_analytics\prediction[])
      */
-    public function get_predictions(\context $context, $page = false, $perpage = 100) {
-        global $DB;
+    public function get_predictions(\context $context, $skiphidden = true, $page = false, $perpage = 100) {
+        global $DB, $USER;
 
         \core_analytics\manager::check_can_list_insights($context);
 
@@ -1111,12 +1111,27 @@ class model {
                   JOIN (
                     SELECT sampleid, max(rangeindex) AS rangeindex
                       FROM {analytics_predictions}
-                     WHERE modelid = ? and contextid = ?
+                     WHERE modelid = :modelidsubap and contextid = :contextidsubap
                     GROUP BY sampleid
                   ) apsub
                   ON ap.sampleid = apsub.sampleid AND ap.rangeindex = apsub.rangeindex
-                 WHERE ap.modelid = ? and ap.contextid = ?";
-        $params = array($this->model->id, $context->id, $this->model->id, $context->id);
+                WHERE ap.modelid = :modelid and ap.contextid = :contextid";
+
+        $params = array('modelid' => $this->model->id, 'contextid' => $context->id,
+            'modelidsubap' => $this->model->id, 'contextidsubap' => $context->id);
+
+        if ($skiphidden) {
+            $sql .= " AND NOT EXISTS (
+              SELECT 1
+                FROM {analytics_prediction_actions} apa
+               WHERE apa.predictionid = ap.id AND apa.userid = :userid AND (apa.actionname = :fixed OR apa.actionname = :notuseful)
+            )";
+            $params['userid'] = $USER->id;
+            $params['fixed'] = \core_analytics\prediction::ACTION_FIXED;
+            $params['notuseful'] = \core_analytics\prediction::ACTION_NOT_USEFUL;
+        }
+
+        $sql .= " ORDER BY ap.timecreated DESC";
         if (!$predictions = $DB->get_records_sql($sql, $params)) {
             return array();
         }
index f738f69..549795e 100644 (file)
@@ -35,6 +35,21 @@ defined('MOODLE_INTERNAL') || die();
  */
 class prediction {
 
+    /**
+     * Prediction details (one of the default prediction actions)
+     */
+    const ACTION_PREDICTION_DETAILS = 'predictiondetails';
+
+    /**
+     * Prediction not useful (one of the default prediction actions)
+     */
+    const ACTION_NOT_USEFUL = 'notuseful';
+
+    /**
+     * Prediction already fixed (one of the default prediction actions)
+     */
+    const ACTION_FIXED = 'fixed';
+
     /**
      * @var \stdClass
      */
@@ -97,6 +112,51 @@ class prediction {
         return $this->calculations;
     }
 
+    /**
+     * Stores the executed action.
+
+     * Prediction instances should be retrieved using \core_analytics\manager::get_prediction,
+     * It is the caller responsability to check that the user can see the prediction.
+     *
+     * @param string $actionname
+     * @param \core_analytics\local\target\base $target
+     */
+    public function action_executed($actionname, \core_analytics\local\target\base $target) {
+        global $USER, $DB;
+
+        $context = \context::instance_by_id($this->get_prediction_data()->contextid, IGNORE_MISSING);
+        if (!$context) {
+            throw new \moodle_exception('errorpredictioncontextnotavailable', 'analytics');
+        }
+
+        // Check that the provided action exists.
+        $actions = $target->prediction_actions($this, true);
+        foreach ($actions as $action) {
+            if ($action->get_action_name() === $actionname) {
+                $found = true;
+            }
+        }
+        if (empty($found)) {
+            throw new \moodle_exception('errorunknownaction', 'analytics');
+        }
+
+        $predictionid = $this->get_prediction_data()->id;
+
+        $action = new \stdClass();
+        $action->predictionid = $predictionid;
+        $action->userid = $USER->id;
+        $action->actionname = $actionname;
+        $action->timecreated = time();
+        $DB->insert_record('analytics_prediction_actions', $action);
+
+        $eventdata = array (
+            'context' => $context,
+            'objectid' => $predictionid,
+            'other' => array('actionname' => $actionname)
+        );
+        \core\event\prediction_action_started::create($eventdata)->trigger();
+    }
+
     /**
      * format_calculations
      *
index db7e50a..2fb4281 100644 (file)
@@ -35,36 +35,54 @@ defined('MOODLE_INTERNAL') || die();
  */
 class prediction_action {
 
+    /**
+     * @var string
+     */
+    protected $actionname = null;
+
     /**
      * @var \action_menu_link
      */
     protected $actionlink = null;
 
     /**
-     * __construct
+     * Prediction action constructor.
      *
-     * @param string $actionname
+     * @param string $actionname They should match a-zA-Z_0-9-, as we apply a PARAM_ALPHANUMEXT filter
      * @param \core_analytics\prediction $prediction
      * @param \moodle_url $actionurl
-     * @param \pix_icon $icon
-     * @param string $text
-     * @param bool $primary
+     * @param \pix_icon $icon Link icon
+     * @param string $text Link text
+     * @param bool $primary Primary button or secondary.
+     * @param array $attributes Link attributes
      * @return void
      */
-    public function __construct($actionname, \core_analytics\prediction $prediction, \moodle_url $actionurl, \pix_icon $icon, $text, $primary = false) {
+    public function __construct($actionname, \core_analytics\prediction $prediction, \moodle_url $actionurl, \pix_icon $icon,
+                                $text, $primary = false, $attributes = array()) {
+
+        $this->actionname = $actionname;
 
         // We want to track how effective are our suggested actions, we pass users through a script that will log these actions.
-        $params = array('action' => $actionname, 'predictionid' => $prediction->get_prediction_data()->id,
+        $params = array('action' => $this->actionname, 'predictionid' => $prediction->get_prediction_data()->id,
             'forwardurl' => $actionurl->out(false));
         $url = new \moodle_url('/report/insights/action.php', $params);
 
         if ($primary === false) {
-            $this->actionlink = new \action_menu_link_secondary($url, $icon, $text);
+            $this->actionlink = new \action_menu_link_secondary($url, $icon, $text, $attributes);
         } else {
-            $this->actionlink = new \action_menu_link_primary($url, $icon, $text);
+            $this->actionlink = new \action_menu_link_primary($url, $icon, $text, $attributes);
         }
     }
 
+    /**
+     * Returns the action name.
+     *
+     * @return string
+     */
+    public function get_action_name() {
+        return $this->actionname;
+    }
+
     /**
      * Returns the link to the action.
      *
index b127fcb..17d12c0 100644 (file)
@@ -53,6 +53,8 @@ $string['errorunexistingtimesplitting'] = 'The selected time-splitting method is
 $string['errorunexistingmodel'] = 'Non-existing model {$a}';
 $string['errorunknownaction'] = 'Unknown action';
 $string['eventpredictionactionstarted'] = 'Prediction process started';
+$string['eventinsightsviewed'] = 'Insights viewed';
+$string['fixedack'] = 'Acknowledged / fixed';
 $string['insightmessagesubject'] = 'New insight for "{$a->contextname}": {$a->insightname}';
 $string['insightinfomessage'] = 'The system generated some insights for you: {$a}';
 $string['insightinfomessagehtml'] = 'The system generated some insights for you: <a href="{$a}">{$a}</a>.';
@@ -71,6 +73,7 @@ $string['nonewtimeranges'] = 'No new time ranges; nothing to predict.';
 $string['nopredictionsyet'] = 'No predictions available yet';
 $string['noranges'] = 'No predictions yet';
 $string['notrainingbasedassumptions'] = 'Models based on assumptions do not need training';
+$string['notuseful'] = 'Not useful';
 $string['novaliddata'] = 'No valid data available';
 $string['novalidsamples'] = 'No valid samples available';
 $string['onlycli'] = 'Analytics processes execution via command line only';
index b773632..baac51d 100644 (file)
@@ -61,25 +61,27 @@ class course_dropout extends \core_analytics\local\target\binary {
     public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false) {
         global $USER;
 
-        $actions = parent::prediction_actions($prediction, $includedetailsaction);
+        $actions = array();
 
         $sampledata = $prediction->get_sample_data();
         $studentid = $sampledata['user']->id;
 
+        $attrs = array('target' => '_blank');
+
         // Send a message.
         $url = new \moodle_url('/message/index.php', array('user' => $USER->id, 'id' => $studentid));
         $pix = new \pix_icon('t/message', get_string('sendmessage', 'message'));
-        $actions['studentmessage'] = new \core_analytics\prediction_action('studentmessage', $prediction, $url, $pix,
-            get_string('sendmessage', 'message'));
+        $actions[] = new \core_analytics\prediction_action('studentmessage', $prediction, $url, $pix,
+            get_string('sendmessage', 'message'), $attrs);
 
         // View outline report.
         $url = new \moodle_url('/report/outline/user.php', array('id' => $studentid, 'course' => $sampledata['course']->id,
             'mode' => 'outline'));
         $pix = new \pix_icon('i/report', get_string('outlinereport'));
-        $actions['viewoutlinereport'] = new \core_analytics\prediction_action('viewoutlinereport', $prediction, $url, $pix,
-            get_string('outlinereport'));
+        $actions[] = new \core_analytics\prediction_action('viewoutlinereport', $prediction, $url, $pix,
+            get_string('outlinereport'), $attrs);
 
-        return $actions;
+        return array_merge($actions, parent::prediction_actions($prediction, $includedetailsaction));
     }
 
     /**
index 7d3714f..85e6513 100644 (file)
@@ -64,25 +64,28 @@ class no_teaching extends \core_analytics\local\target\binary {
      */
     public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false) {
 
-        // No need to call the parent as the parent's action is view details and this target only have 1 feature.
-        $actions = array();
-
         $sampledata = $prediction->get_sample_data();
         $course = $sampledata['course'];
 
+        $actions = array();
+
         $url = new \moodle_url('/course/view.php', array('id' => $course->id));
         $pix = new \pix_icon('i/course', get_string('course'));
-        $actions['viewcourse'] = new \core_analytics\prediction_action('viewcourse', $prediction,
+        $actions[] = new \core_analytics\prediction_action('viewcourse', $prediction,
             $url, $pix, get_string('view'));
 
         if (has_any_capability(['moodle/course:viewparticipants', 'moodle/course:enrolreview'], $sampledata['context'])) {
             $url = new \moodle_url('/user/index.php', array('id' => $course->id));
             $pix = new \pix_icon('i/cohort', get_string('participants'));
-            $actions['viewparticipants'] = new \core_analytics\prediction_action('viewparticipants', $prediction,
+            $actions[] = new \core_analytics\prediction_action('viewparticipants', $prediction,
                 $url, $pix, get_string('participants'));
         }
 
-        return $actions;
+        $parentactions = parent::prediction_actions($prediction, $includedetailsaction);
+        // No need to show details as there is only 1 indicator.
+        unset($parentactions[\core_analytics\prediction::ACTION_PREDICTION_DETAILS]);
+
+        return array_merge($actions, $parentactions);
     }
 
     /**
diff --git a/lib/classes/event/insights_viewed.php b/lib/classes/event/insights_viewed.php
new file mode 100644 (file)
index 0000000..357474e
--- /dev/null
@@ -0,0 +1,79 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Insights page viewed event.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      - string modelid: The model id
+ * }
+ *
+ * @package    core_analytics
+ * @copyright  2017 David Monllao {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Event triggered after a user views the insights page.
+ *
+ * @package    core_analytics
+ * @copyright  2017 David Monllao {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class insights_viewed extends \core\event\base {
+
+    /**
+     * Set basic properties for the event.
+     */
+    protected function init() {
+        $this->data['crud'] = 'r';
+        // It depends on the insight really.
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventinsightsviewed', 'analytics');
+    }
+
+    /**
+     * Returns non-localised event description with id's for admin use only.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' has viewed model '{$this->other['modelid']}' insights in " .
+            "context with id '{$this->data['contextid']}'";
+    }
+
+    /**
+     * Returns relevant URL.
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return new \moodle_url('/report/insights/insights.php', array('modelid' => $this->other['modelid'],
+            'contextid' => $this->data['contextid']));
+    }
+}
index d35b5a8..7e4b4f3 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20170814" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20170904" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
         <INDEX NAME="starttime-endtime-contextid" UNIQUE="false" FIELDS="starttime, endtime, contextid"/>
       </INDEXES>
     </TABLE>
+    <TABLE NAME="analytics_prediction_actions" COMMENT="Register of user actions over predictions.">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="predictionid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="actionname" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="predictionid" TYPE="foreign" FIELDS="predictionid" REFTABLE="analytics_predictions" REFFIELDS="id"/>
+        <KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id"/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="predictionidanduseridandactionname" UNIQUE="false" FIELDS="predictionid, userid, actionname"/>
+      </INDEXES>
+    </TABLE>
   </TABLES>
 </XMLDB>
index 945b3bf..5a96d9f 100644 (file)
@@ -2436,5 +2436,35 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2017082800.00);
     }
 
+    if ($oldversion < 2017090700.01) {
+
+        // Define table analytics_prediction_actions to be created.
+        $table = new xmldb_table('analytics_prediction_actions');
+
+        // Adding fields to table analytics_prediction_actions.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('predictionid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('actionname', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+
+        // Adding keys to table analytics_prediction_actions.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
+        $table->add_key('predictionid', XMLDB_KEY_FOREIGN, array('predictionid'), 'analytics_predictions', array('id'));
+        $table->add_key('userid', XMLDB_KEY_FOREIGN, array('userid'), 'user', array('id'));
+
+        // Adding indexes to table analytics_prediction_actions.
+        $table->add_index('predictionidanduseridandactionname', XMLDB_INDEX_NOTUNIQUE,
+            array('predictionid', 'userid', 'actionname'));
+
+        // Conditionally launch create table for analytics_prediction_actions.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2017090700.01);
+    }
+
     return true;
 }
index 5e94ff5..452f9d9 100644 (file)
@@ -111,4 +111,10 @@ echo $OUTPUT->header();
 $renderable = new \report_insights\output\insights_list($model, $context, $othermodels, $page, $perpage);
 echo $renderer->render($renderable);
 
+$eventdata = array (
+    'context' => $context,
+    'other' => array('modelid' => $model->get_id())
+);
+\core\event\insights_viewed::create($eventdata)->trigger();
+
 echo $OUTPUT->footer();
index 5460083..803b74a 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2017090700.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2017090700.01;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.