See Release Notes
Long Term Support Release
Differences Between: [Versions 401 and 402] [Versions 401 and 403]
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 * Moodle course analysable 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 25 namespace core_analytics; 26 27 defined('MOODLE_INTERNAL') || die(); 28 29 require_once($CFG->dirroot . '/course/lib.php'); 30 require_once($CFG->dirroot . '/lib/gradelib.php'); 31 require_once($CFG->dirroot . '/lib/enrollib.php'); 32 33 /** 34 * Moodle course analysable 35 * 36 * @package core_analytics 37 * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} 38 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 39 */ 40 class course implements \core_analytics\analysable { 41 42 /** 43 * @var bool Has this course data been already loaded. 44 */ 45 protected $loaded = false; 46 47 /** 48 * @var int $cachedid self::$cachedinstance analysable id. 49 */ 50 protected static $cachedid = 0; 51 52 /** 53 * @var \core_analytics\course $cachedinstance 54 */ 55 protected static $cachedinstance = null; 56 57 /** 58 * Course object 59 * 60 * @var \stdClass 61 */ 62 protected $course = null; 63 64 /** 65 * The course context. 66 * 67 * @var \context_course 68 */ 69 protected $coursecontext = null; 70 71 /** 72 * The course activities organized by activity type. 73 * 74 * @var array 75 */ 76 protected $courseactivities = array(); 77 78 /** 79 * Course start time. 80 * 81 * @var int 82 */ 83 protected $starttime = null; 84 85 86 /** 87 * Has the course already started? 88 * 89 * @var bool 90 */ 91 protected $started = null; 92 93 /** 94 * Course end time. 95 * 96 * @var int 97 */ 98 protected $endtime = null; 99 100 /** 101 * Is the course finished? 102 * 103 * @var bool 104 */ 105 protected $finished = null; 106 107 /** 108 * Course students ids. 109 * 110 * @var int[] 111 */ 112 protected $studentids = []; 113 114 115 /** 116 * Course teachers ids 117 * 118 * @var int[] 119 */ 120 protected $teacherids = []; 121 122 /** 123 * Cached copy of the total number of logs in the course. 124 * 125 * @var int 126 */ 127 protected $ntotallogs = null; 128 129 /** 130 * Course manager constructor. 131 * 132 * Use self::instance() instead to get cached copies of the course. Instances obtained 133 * through this constructor will not be cached. 134 * 135 * @param int|\stdClass $course Course id or mdl_course record 136 * @param \context|null $context 137 * @return void 138 */ 139 public function __construct($course, ?\context $context = null) { 140 141 if (is_scalar($course)) { 142 $this->course = new \stdClass(); 143 $this->course->id = $course; 144 } else { 145 $this->course = $course; 146 } 147 148 if (!is_null($context)) { 149 $this->coursecontext = $context; 150 } 151 } 152 153 /** 154 * Returns an analytics course instance. 155 * 156 * Lazy load of course data, students and teachers. 157 * 158 * @param int|\stdClass $course Course object or course id 159 * @param \context|null $context 160 * @return \core_analytics\course 161 */ 162 public static function instance($course, ?\context $context = null) { 163 164 $courseid = $course; 165 if (!is_scalar($courseid)) { 166 $courseid = $course->id; 167 } 168 169 if (self::$cachedid === $courseid) { 170 return self::$cachedinstance; 171 } 172 173 $cachedinstance = new \core_analytics\course($course, $context); 174 self::$cachedinstance = $cachedinstance; 175 self::$cachedid = (int)$courseid; 176 return self::$cachedinstance; 177 } 178 179 /** 180 * get_id 181 * 182 * @return int 183 */ 184 public function get_id() { 185 return $this->course->id; 186 } 187 188 /** 189 * Loads the analytics course object. 190 * 191 * @return void 192 */ 193 protected function load() { 194 195 // The instance constructor could be already loaded with the full course object. Using shortname 196 // because it is a required course field. 197 if (empty($this->course->shortname)) { 198 $this->course = get_course($this->course->id); 199 } 200 201 $this->coursecontext = $this->get_context(); 202 203 $this->now = time(); 204 205 // Get the course users, including users assigned to student and teacher roles at an higher context. 206 $cache = \cache::make_from_params(\cache_store::MODE_REQUEST, 'core_analytics', 'rolearchetypes'); 207 208 // Flag the instance as loaded. 209 $this->loaded = true; 210 211 if (!$studentroles = $cache->get('student')) { 212 $studentroles = array_keys(get_archetype_roles('student')); 213 $cache->set('student', $studentroles); 214 } 215 $this->studentids = $this->get_user_ids($studentroles); 216 217 if (!$teacherroles = $cache->get('teacher')) { 218 $teacherroles = array_keys(get_archetype_roles('editingteacher') + get_archetype_roles('teacher')); 219 $cache->set('teacher', $teacherroles); 220 } 221 $this->teacherids = $this->get_user_ids($teacherroles); 222 } 223 224 /** 225 * The course short name 226 * 227 * @return string 228 */ 229 public function get_name() { 230 return format_string($this->get_course_data()->shortname, true, array('context' => $this->get_context())); 231 } 232 233 /** 234 * get_context 235 * 236 * @return \context 237 */ 238 public function get_context() { 239 if ($this->coursecontext === null) { 240 $this->coursecontext = \context_course::instance($this->course->id); 241 } 242 return $this->coursecontext; 243 } 244 245 /** 246 * Get the course start timestamp. 247 * 248 * @return int Timestamp or 0 if has not started yet. 249 */ 250 public function get_start() { 251 252 if ($this->starttime !== null) { 253 return $this->starttime; 254 } 255 256 // The field always exist but may have no valid if the course is created through a sync process. 257 if (!empty($this->get_course_data()->startdate)) { 258 $this->starttime = (int)$this->get_course_data()->startdate; 259 } else { 260 $this->starttime = 0; 261 } 262 263 return $this->starttime; 264 } 265 266 /** 267 * Guesses the start of the course based on students' activity and enrolment start dates. 268 * 269 * @return int 270 */ 271 public function guess_start() { 272 global $DB; 273 274 if (!$this->get_total_logs()) { 275 // Can't guess. 276 return 0; 277 } 278 279 if (!$logstore = \core_analytics\manager::get_analytics_logstore()) { 280 return 0; 281 } 282 283 // We first try to find current course student logs. 284 $firstlogs = array(); 285 foreach ($this->get_students() as $studentid) { 286 // Grrr, we are limited by logging API, we could do this easily with a 287 // select min(timecreated) from xx where courseid = yy group by userid. 288 289 // Filters based on the premise that more than 90% of people will be using 290 // standard logstore, which contains a userid, contextlevel, contextinstanceid index. 291 $select = "userid = :userid AND contextlevel = :contextlevel AND contextinstanceid = :contextinstanceid"; 292 $params = array('userid' => $studentid, 'contextlevel' => CONTEXT_COURSE, 'contextinstanceid' => $this->get_id()); 293 $events = $logstore->get_events_select($select, $params, 'timecreated ASC', 0, 1); 294 if ($events) { 295 $event = reset($events); 296 $firstlogs[] = $event->timecreated; 297 } 298 } 299 if (empty($firstlogs)) { 300 // Can't guess if no student accesses. 301 return 0; 302 } 303 304 sort($firstlogs); 305 $firstlogsmedian = $this->median($firstlogs); 306 307 $studentenrolments = enrol_get_course_users($this->get_id(), $this->get_students()); 308 if (empty($studentenrolments)) { 309 return 0; 310 } 311 312 $enrolstart = array(); 313 foreach ($studentenrolments as $studentenrolment) { 314 $enrolstart[] = ($studentenrolment->uetimestart) ? $studentenrolment->uetimestart : $studentenrolment->uetimecreated; 315 } 316 sort($enrolstart); 317 $enrolstartmedian = $this->median($enrolstart); 318 319 return intval(($enrolstartmedian + $firstlogsmedian) / 2); 320 } 321 322 /** 323 * Get the course end timestamp. 324 * 325 * @return int Timestamp or 0 if time end was not set. 326 */ 327 public function get_end() { 328 global $DB; 329 330 if ($this->endtime !== null) { 331 return $this->endtime; 332 } 333 334 // The enddate field is only available from Moodle 3.2 (MDL-22078). 335 if (!empty($this->get_course_data()->enddate)) { 336 $this->endtime = (int)$this->get_course_data()->enddate; 337 return $this->endtime; 338 } 339 340 return 0; 341 } 342 343 /** 344 * Get the course end timestamp. 345 * 346 * @return int Timestamp, \core_analytics\analysable::MAX_TIME if we don't know but ongoing and 0 if we can not work it out. 347 */ 348 public function guess_end() { 349 global $DB; 350 351 if ($this->get_total_logs() === 0) { 352 // No way to guess if there are no logs. 353 $this->endtime = 0; 354 return $this->endtime; 355 } 356 357 list($filterselect, $filterparams) = $this->course_students_query_filter('ula'); 358 359 // Consider the course open if there are still student accesses. 360 $monthsago = time() - (WEEKSECS * 4 * 2); 361 $select = $filterselect . ' AND timeaccess > :timeaccess'; 362 $params = $filterparams + array('timeaccess' => $monthsago); 363 $sql = "SELECT DISTINCT timeaccess FROM {user_lastaccess} ula 364 JOIN {enrol} e ON e.courseid = ula.courseid 365 JOIN {user_enrolments} ue ON e.id = ue.enrolid AND ue.userid = ula.userid 366 WHERE $select"; 367 if ($records = $DB->get_records_sql($sql, $params)) { 368 return 0; 369 } 370 371 $sql = "SELECT DISTINCT timeaccess FROM {user_lastaccess} ula 372 JOIN {enrol} e ON e.courseid = ula.courseid 373 JOIN {user_enrolments} ue ON e.id = ue.enrolid AND ue.userid = ula.userid 374 WHERE $filterselect AND ula.timeaccess != 0 375 ORDER BY timeaccess DESC"; 376 $studentlastaccesses = $DB->get_fieldset_sql($sql, $filterparams); 377 if (empty($studentlastaccesses)) { 378 return 0; 379 } 380 sort($studentlastaccesses); 381 382 return $this->median($studentlastaccesses); 383 } 384 385 /** 386 * Returns a course plain object. 387 * 388 * @return \stdClass 389 */ 390 public function get_course_data() { 391 392 if (!$this->loaded) { 393 $this->load(); 394 } 395 396 return $this->course; 397 } 398 399 /** 400 * Has the course started? 401 * 402 * @return bool 403 */ 404 public function was_started() { 405 406 if ($this->started === null) { 407 if ($this->get_start() === 0 || $this->now < $this->get_start()) { 408 // Not yet started. 409 $this->started = false; 410 } else { 411 $this->started = true; 412 } 413 } 414 415 return $this->started; 416 } 417 418 /** 419 * Has the course finished? 420 * 421 * @return bool 422 */ 423 public function is_finished() { 424 425 if ($this->finished === null) { 426 $endtime = $this->get_end(); 427 if ($endtime === 0 || $this->now < $endtime) { 428 // It is not yet finished or no idea when it finishes. 429 $this->finished = false; 430 } else { 431 $this->finished = true; 432 } 433 } 434 435 return $this->finished; 436 } 437 438 /** 439 * Returns a list of user ids matching the specified roles in this course. 440 * 441 * @param array $roleids 442 * @return array 443 */ 444 public function get_user_ids($roleids) { 445 446 // We need to index by ra.id as a user may have more than 1 $roles role. 447 $records = get_role_users($roleids, $this->get_context(), true, 'ra.id, u.id AS userid, r.id AS roleid', 'ra.id ASC'); 448 449 // If a user have more than 1 $roles role array_combine will discard the duplicate. 450 $callable = array($this, 'filter_user_id'); 451 $userids = array_values(array_map($callable, $records)); 452 return array_combine($userids, $userids); 453 } 454 455 /** 456 * Returns the course students. 457 * 458 * @return int[] 459 */ 460 public function get_students() { 461 462 if (!$this->loaded) { 463 $this->load(); 464 } 465 466 return $this->studentids; 467 } 468 469 /** 470 * Returns the total number of student logs in the course 471 * 472 * @return int 473 */ 474 public function get_total_logs() { 475 global $DB; 476 477 // No logs if no students. 478 if (empty($this->get_students())) { 479 return 0; 480 } 481 482 if ($this->ntotallogs === null) { 483 list($filterselect, $filterparams) = $this->course_students_query_filter(); 484 if (!$logstore = \core_analytics\manager::get_analytics_logstore()) { 485 $this->ntotallogs = 0; 486 } else { 487 $this->ntotallogs = $logstore->get_events_select_count($filterselect, $filterparams); 488 } 489 } 490 491 return $this->ntotallogs; 492 } 493 494 /** 495 * Returns all the activities of the provided type the course has. 496 * 497 * @param string $activitytype 498 * @return array 499 */ 500 public function get_all_activities($activitytype) { 501 502 // Using is set because we set it to false if there are no activities. 503 if (!isset($this->courseactivities[$activitytype])) { 504 $modinfo = get_fast_modinfo($this->get_course_data(), -1); 505 $instances = $modinfo->get_instances_of($activitytype); 506 507 if ($instances) { 508 $this->courseactivities[$activitytype] = array(); 509 foreach ($instances as $instance) { 510 // By context. 511 $this->courseactivities[$activitytype][$instance->context->id] = $instance; 512 } 513 } else { 514 $this->courseactivities[$activitytype] = false; 515 } 516 } 517 518 return $this->courseactivities[$activitytype]; 519 } 520 521 /** 522 * Returns the course students grades. 523 * 524 * @param array $courseactivities 525 * @return array 526 */ 527 public function get_student_grades($courseactivities) { 528 529 if (empty($courseactivities)) { 530 return array(); 531 } 532 533 $grades = array(); 534 foreach ($courseactivities as $contextid => $instance) { 535 $gradesinfo = grade_get_grades($this->course->id, 'mod', $instance->modname, $instance->instance, $this->studentids); 536 537 // Sort them by activity context and user. 538 if ($gradesinfo && $gradesinfo->items) { 539 foreach ($gradesinfo->items as $gradeitem) { 540 foreach ($gradeitem->grades as $userid => $grade) { 541 if (empty($grades[$contextid][$userid])) { 542 // Initialise it as array because a single activity can have multiple grade items (e.g. workshop). 543 $grades[$contextid][$userid] = array(); 544 } 545 $grades[$contextid][$userid][$gradeitem->id] = $grade; 546 } 547 } 548 } 549 } 550 551 return $grades; 552 } 553 554 /** 555 * Used by get_user_ids to extract the user id. 556 * 557 * @param \stdClass $record 558 * @return int The user id. 559 */ 560 protected function filter_user_id($record) { 561 return $record->userid; 562 } 563 564 /** 565 * Returns the average time between 2 timestamps. 566 * 567 * @param int $start 568 * @param int $end 569 * @return array [starttime, averagetime, endtime] 570 */ 571 protected function update_loop_times($start, $end) { 572 $avg = intval(($start + $end) / 2); 573 return array($start, $avg, $end); 574 } 575 576 /** 577 * Returns the query and params used to filter the logstore by this course students. 578 * 579 * @param string $prefix 580 * @return array 581 */ 582 protected function course_students_query_filter($prefix = false) { 583 global $DB; 584 585 if ($prefix) { 586 $prefix = $prefix . '.'; 587 } 588 589 // Check the amount of student logs in the 4 previous weeks. 590 list($studentssql, $studentsparams) = $DB->get_in_or_equal($this->get_students(), SQL_PARAMS_NAMED); 591 $filterselect = $prefix . 'courseid = :courseid AND ' . $prefix . 'userid ' . $studentssql; 592 $filterparams = array('courseid' => $this->course->id) + $studentsparams; 593 594 return array($filterselect, $filterparams); 595 } 596 597 /** 598 * Calculate median 599 * 600 * Keys are ignored. 601 * 602 * @param int[]|float[] $values Sorted array of values 603 * @return int 604 */ 605 protected function median($values) { 606 $count = count($values); 607 608 if ($count === 1) { 609 return reset($values); 610 } 611 612 $middlevalue = (int)floor(($count - 1) / 2); 613 614 if ($count % 2) { 615 // Odd number, middle is the median. 616 $median = $values[$middlevalue]; 617 } else { 618 // Even number, calculate avg of 2 medians. 619 $low = $values[$middlevalue]; 620 $high = $values[$middlevalue + 1]; 621 $median = (($low + $high) / 2); 622 } 623 return intval($median); 624 } 625 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body