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 * Base class for targets whose analysable is a course using user enrolments as samples. 19 * 20 * @package core_course 21 * @copyright 2019 Victor Deniz <victor@moodle.com> 22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 */ 24 25 namespace core_course\analytics\target; 26 27 defined('MOODLE_INTERNAL') || die(); 28 29 /** 30 * Base class for targets whose analysable is a course using user enrolments as samples. 31 * 32 * @package core_course 33 * @copyright 2019 Victor Deniz <victor@moodle.com> 34 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 35 */ 36 abstract class course_enrolments extends \core_analytics\local\target\binary { 37 38 /** 39 * @var string 40 */ 41 const MESSAGE_ACTION_NAME = 'studentmessage'; 42 43 /** 44 * @var float 45 */ 46 const ENROL_ACTIVE_PERCENT_REQUIRED = 0.7; 47 48 /** 49 * Students in the course. 50 * @var int[] 51 */ 52 protected $students; 53 54 /** 55 * Returns the analyser class that should be used along with this target. 56 * 57 * @return string The full class name as a string 58 */ 59 public function get_analyser_class() { 60 return '\core\analytics\analyser\student_enrolments'; 61 } 62 63 /** 64 * Only past stuff. 65 * 66 * @param \core_analytics\local\time_splitting\base $timesplitting 67 * @return bool 68 */ 69 public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool { 70 return ($timesplitting instanceof \core_analytics\local\time_splitting\before_now); 71 } 72 73 /** 74 * Overwritten to show a simpler language string. 75 * 76 * @param int $modelid 77 * @param \context $context 78 * @return string 79 */ 80 public function get_insight_subject(int $modelid, \context $context) { 81 return get_string('studentsatriskincourse', 'course', $context->get_context_name(false)); 82 } 83 84 /** 85 * Returns the body message for the insight. 86 * 87 * @param \context $context 88 * @param string $contextname 89 * @param \stdClass $user 90 * @param \moodle_url $insighturl 91 * @return string[] The plain text message and the HTML message 92 */ 93 public function get_insight_body(\context $context, string $contextname, \stdClass $user, \moodle_url $insighturl): array { 94 global $OUTPUT; 95 96 $a = (object)['coursename' => $contextname, 'userfirstname' => $user->firstname]; 97 $fullmessage = get_string('studentsatriskinfomessage', 'course', $a) . PHP_EOL . PHP_EOL . $insighturl->out(false); 98 $fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message', 99 ['url' => $insighturl->out(false), 'insightinfomessage' => get_string('studentsatriskinfomessage', 'course', $a)] 100 ); 101 102 return [$fullmessage, $fullmessagehtml]; 103 } 104 105 /** 106 * Discards courses that are not yet ready to be used for training or prediction. 107 * 108 * @param \core_analytics\analysable $course 109 * @param bool $fortraining 110 * @return true|string 111 */ 112 public function is_valid_analysable(\core_analytics\analysable $course, $fortraining = true) { 113 114 if (!$course->was_started()) { 115 return get_string('coursenotyetstarted', 'course'); 116 } 117 118 if (!$fortraining && !$course->get_course_data()->visible) { 119 return get_string('hiddenfromstudents'); 120 } 121 122 if (!$this->students = $course->get_students()) { 123 return get_string('nocoursestudents', 'course'); 124 } 125 126 if (!course_format_uses_sections($course->get_course_data()->format)) { 127 // We can not split activities in time ranges. 128 return get_string('nocoursesections', 'course'); 129 } 130 131 if ($course->get_end() == 0) { 132 // We require time end to be set. 133 return get_string('nocourseendtime', 'course'); 134 } 135 136 if ($course->get_end() < $course->get_start()) { 137 return get_string('errorendbeforestart', 'course'); 138 } 139 140 // A course that lasts longer than 1 year probably have wrong start or end dates. 141 if ($course->get_end() - $course->get_start() > (YEARSECS + (WEEKSECS * 4))) { 142 return get_string('coursetoolong', 'course'); 143 } 144 145 // Finished courses can not be used to get predictions. 146 if (!$fortraining && $course->is_finished()) { 147 return get_string('coursealreadyfinished', 'course'); 148 } 149 150 if ($fortraining) { 151 // Ongoing courses data can not be used to train. 152 if (!$course->is_finished()) { 153 return get_string('coursenotyetfinished', 'course'); 154 } 155 } 156 157 return true; 158 } 159 160 /** 161 * Discard student enrolments that are invalid. 162 * 163 * Note that this method assumes that the target is only interested in enrolments that are/were active 164 * between the current course start and end times. Targets interested in predicting students at risk before 165 * their enrolment start and targets interested in getting predictions for students whose enrolment already 166 * finished should overwrite this method as these students are discarded by this method. 167 * 168 * @param int $sampleid 169 * @param \core_analytics\analysable $course 170 * @param bool $fortraining 171 * @return bool 172 */ 173 public function is_valid_sample($sampleid, \core_analytics\analysable $course, $fortraining = true) { 174 175 $now = time(); 176 177 $userenrol = $this->retrieve('user_enrolments', $sampleid); 178 if ($userenrol->timeend && $course->get_start() > $userenrol->timeend) { 179 // Discard enrolments which time end is prior to the course start. This should get rid of 180 // old user enrolments that remain on the course. 181 return false; 182 } 183 184 $limit = $course->get_start() - (YEARSECS + (WEEKSECS * 4)); 185 if (($userenrol->timestart && $userenrol->timestart < $limit) || 186 (!$userenrol->timestart && $userenrol->timecreated < $limit)) { 187 // Following what we do in is_valid_analysable, we will discard enrolments that last more than 1 academic year 188 // because they have incorrect start and end dates or because they are reused along multiple years 189 // without removing previous academic years students. This may not be very accurate because some courses 190 // can last just some months, but it is better than nothing. 191 return false; 192 } 193 194 if ($course->get_end()) { 195 if (($userenrol->timestart && $userenrol->timestart > $course->get_end()) || 196 (!$userenrol->timestart && $userenrol->timecreated > $course->get_end())) { 197 // Discard user enrolments that start after the analysable official end. 198 return false; 199 } 200 201 } 202 203 if ($now < $userenrol->timestart && $userenrol->timestart) { 204 // Discard enrolments whose start date is after now (no need to check timecreated > $now :P). 205 return false; 206 } 207 208 if (!$fortraining && $userenrol->timeend && $userenrol->timeend < $now) { 209 // We don't want to generate predictions for finished enrolments. 210 return false; 211 } 212 213 return true; 214 } 215 216 /** 217 * prediction_actions 218 * 219 * @param \core_analytics\prediction $prediction 220 * @param bool $includedetailsaction 221 * @param bool $isinsightuser 222 * @return \core_analytics\prediction_action[] 223 */ 224 public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false, 225 $isinsightuser = false) { 226 227 $actions = array(); 228 229 $sampledata = $prediction->get_sample_data(); 230 $studentid = $sampledata['user']->id; 231 232 // View outline report. 233 $url = new \moodle_url('/report/outline/user.php', array('id' => $studentid, 'course' => $sampledata['course']->id, 234 'mode' => 'outline')); 235 $pix = new \pix_icon('i/report', get_string('outlinereport')); 236 $actions[] = new \core_analytics\prediction_action('viewoutlinereport', $prediction, $url, $pix, 237 get_string('outlinereport'), false, ['target' => '_blank']); 238 239 return array_merge(parent::prediction_actions($prediction, $includedetailsaction, $isinsightuser), $actions); 240 } 241 242 /** 243 * Suggested bulk actions for a user. 244 * 245 * @param \core_analytics\prediction[] $predictions List of predictions suitable for the bulk actions to use. 246 * @return \core_analytics\bulk_action[] The list of bulk actions. 247 */ 248 public function bulk_actions(array $predictions) { 249 250 $actions = []; 251 252 $userids = []; 253 foreach ($predictions as $prediction) { 254 $sampledata = $prediction->get_sample_data(); 255 $userid = $sampledata['user']->id; 256 257 // Indexed by prediction id because we want the predictionid-userid 258 // mapping later when sending the message. 259 $userids[$prediction->get_prediction_data()->id] = $userid; 260 } 261 262 // Send a message for all the students. 263 $attrs = array( 264 'data-bulk-sendmessage' => '1', 265 'data-prediction-to-user-id' => json_encode($userids) 266 ); 267 $actions[] = new \core_analytics\bulk_action(self::MESSAGE_ACTION_NAME, new \moodle_url(''), 268 new \pix_icon('t/message', get_string('sendmessage', 'message')), 269 get_string('sendmessage', 'message'), true, $attrs); 270 271 return array_merge($actions, parent::bulk_actions($predictions)); 272 } 273 274 /** 275 * Adds the JS required to run the bulk actions. 276 */ 277 public function add_bulk_actions_js() { 278 global $PAGE; 279 280 $PAGE->requires->js_call_amd('report_insights/message_users', 'init', 281 ['.insights-bulk-actions', self::MESSAGE_ACTION_NAME]); 282 parent::add_bulk_actions_js(); 283 } 284 285 /** 286 * Is/was this user enrolment active during most of the analysis interval? 287 * 288 * This method discards enrolments that were not active during most of the analysis interval. It is 289 * important to discard these enrolments because the indicator calculations can lead to misleading 290 * results. 291 * 292 * Note that this method assumes that the target is interested in enrolments that are/were active 293 * during the analysis interval. Targets interested in predicting students at risk before 294 * their enrolment start should not call this method. Similarly, targets interested in getting 295 * predictions for students whose enrolment already finished should not call this method either. 296 * 297 * @param int $sampleid The id of the sample that is being calculated 298 * @param int $starttime The analysis interval start time 299 * @param int $endtime The analysis interval end time 300 * @return bool 301 */ 302 protected function enrolment_active_during_analysis_time(int $sampleid, int $starttime, int $endtime) { 303 304 $userenrol = $this->retrieve('user_enrolments', $sampleid); 305 306 if (!empty($userenrol->timestart)) { 307 $enrolstart = $userenrol->timestart; 308 } else { 309 // This is always set. 310 $enrolstart = $userenrol->timecreated; 311 } 312 313 if (!empty($userenrol->timeend)) { 314 $enrolend = $userenrol->timeend; 315 } else { 316 // Default to tre end of the world. 317 $enrolend = PHP_INT_MAX; 318 } 319 320 if ($endtime && $endtime < $enrolstart) { 321 /* The enrolment starts/ed after the analysis end time. 322 * |=========| |----------| 323 * A start A end E start E end 324 */ 325 return false; 326 } 327 328 if ($starttime && $enrolend < $starttime) { 329 /* The enrolment finishes/ed before the analysis start time. 330 * |---------| |==========| 331 * E start E end A start A end 332 */ 333 return false; 334 } 335 336 // Now we want to discard enrolments that were not active for most of the analysis interval. We 337 // need both a $starttime and an $endtime to calculate this. 338 339 if (!$starttime) { 340 // Early return. Nothing to discard if there is no start. 341 return true; 342 } 343 344 if (!$endtime) { 345 // We can not calculate in relative terms (percent) how far from the analysis start time 346 // this enrolment start is/was. 347 return true; 348 } 349 350 if ($enrolstart < $starttime && $endtime < $enrolend) { 351 /* The enrolment is active during all the analysis time. 352 * |-----------------------------| 353 * |========| 354 * E start A start A end E end 355 */ 356 return true; 357 } 358 359 // If we reach this point is because the enrolment is only active for a portion of the analysis interval. 360 // Therefore, we check that it was active for most of the analysis interval, a self::ENROL_ACTIVE_PERCENT_REQUIRED. 361 362 if ($starttime <= $enrolstart && $enrolend <= $endtime) { 363 /* |=============================| 364 * |--------| 365 * A start E start E end A end 366 */ 367 $activeenrolduration = $enrolend - $enrolstart; 368 } else if ($enrolstart <= $starttime && $enrolend <= $endtime) { 369 /* |===================| 370 * |------------------| 371 * E start A start E end A end 372 */ 373 $activeenrolduration = $enrolend - $starttime; 374 } else if ($starttime <= $enrolstart && $endtime <= $enrolend) { 375 /* |===================| 376 * |------------------| 377 * A start E start A end E end 378 */ 379 $activeenrolduration = $endtime - $enrolstart; 380 } 381 382 $analysisduration = $endtime - $starttime; 383 384 if (floatval($activeenrolduration) / floatval($analysisduration) < self::ENROL_ACTIVE_PERCENT_REQUIRED) { 385 // The student was not enroled in the course for most of the analysis interval. 386 return false; 387 } 388 389 // We happily return true if the enrolment was active for more than self::ENROL_ACTIVE_PERCENT_REQUIRED of 390 // the analysis interval. 391 return true; 392 } 393 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body