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