f0482dbac4c300d8cff9c6392a5cfa3937c02cf3
[moodle.git] / lib / completionlib.php
1 <?php
3 // This file is part of Moodle - http://moodle.org/
4 //
5 // Moodle is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
9 //
10 // Moodle is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 // GNU General Public License for more details.
14 //
15 // You should have received a copy of the GNU General Public License
16 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
18 /**
19  * Contains a class used for tracking whether activities have been completed
20  * by students ('completion')
21  *
22  * Completion top-level options (admin setting enablecompletion)
23  *
24  * @package    core
25  * @subpackage completion
26  * @copyright  1999 onwards Martin Dougiamas   {@link http://moodle.com}
27  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
28  */
30 defined('MOODLE_INTERNAL') || die();
32 require_once $CFG->libdir.'/completion/completion_aggregation.php';
33 require_once $CFG->libdir.'/completion/completion_criteria.php';
34 require_once $CFG->libdir.'/completion/completion_completion.php';
35 require_once $CFG->libdir.'/completion/completion_criteria_completion.php';
38 /** The completion system is enabled in this site/course */
39 define('COMPLETION_ENABLED', 1);
40 /** The completion system is not enabled in this site/course */
41 define('COMPLETION_DISABLED', 0);
43 // Completion tracking options per-activity (course_modules/completion)
45 /** Completion tracking is disabled for this activity */
46 define('COMPLETION_TRACKING_NONE', 0);
47 /** Manual completion tracking (user ticks box) is enabled for this activity */
48 define('COMPLETION_TRACKING_MANUAL', 1);
49 /** Automatic completion tracking (system ticks box) is enabled for this activity */
50 define('COMPLETION_TRACKING_AUTOMATIC', 2);
52 // Completion state values (course_modules_completion/completionstate)
54 /** The user has not completed this activity. */
55 define('COMPLETION_INCOMPLETE', 0);
56 /** The user has completed this activity. It is not specified whether they have
57  * passed or failed it. */
58 define('COMPLETION_COMPLETE', 1);
59 /** The user has completed this activity with a grade above the pass mark. */
60 define('COMPLETION_COMPLETE_PASS', 2);
61 /** The user has completed this activity but their grade is less than the pass mark */
62 define('COMPLETION_COMPLETE_FAIL', 3);
64 // Completion effect changes (used only in update_state)
66 /** The effect of this change to completion status is unknown. */
67 define('COMPLETION_UNKNOWN', -1);
68 /** The user's grade has changed, so their new state might be
69  * COMPLETION_COMPLETE_PASS or COMPLETION_COMPLETE_FAIL. */
70 // TODO Is this useful?
71 define('COMPLETION_GRADECHANGE', -2);
73 // Whether view is required to create an activity (course_modules/completionview)
75 /** User must view this activity */
76 define('COMPLETION_VIEW_REQUIRED', 1);
77 /** User does not need to view this activity */
78 define('COMPLETION_VIEW_NOT_REQUIRED', 0);
80 // Completion viewed state (course_modules_completion/viewed)
82 /** User has viewed this activity */
83 define('COMPLETION_VIEWED', 1);
84 /** User has not viewed this activity */
85 define('COMPLETION_NOT_VIEWED', 0);
87 // Completion cacheing
89 /** Cache expiry time in seconds (10 minutes) */
90 define('COMPLETION_CACHE_EXPIRY', 10*60);
92 // Combining completion condition. This is also the value you should return
93 // if you don't have any applicable conditions. Used for activity completion.
94 /** Completion details should be ORed together and you should return false if
95  none apply */
96 define('COMPLETION_OR', false);
97 /** Completion details should be ANDed together and you should return true if
98  none apply */
99 define('COMPLETION_AND', true);
101 // Course completion criteria aggregation methods
102 define('COMPLETION_AGGREGATION_ALL',        1);
103 define('COMPLETION_AGGREGATION_ANY',        2);
106 /**
107  * Class represents completion information for a course.
108  *
109  * Does not contain any data, so you can safely construct it multiple times
110  * without causing any problems.
111  *
112  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
113  * @package moodlecore
114  */
115 class completion_info {
116     /**
117      * Course object passed during construction
118      * @access  private
119      * @var     object
120      */
121     private $course;
123     /**
124      * Course id
125      * @access  public
126      * @var     int
127      */
128     public $course_id;
130     /**
131      * Completion criteria
132      * @access  private
133      * @var     array
134      * @see     completion_info->get_criteria()
135      */
136     private $criteria;
138     /**
139      * Return array of aggregation methods
140      * @access  public
141      * @return  array
142      */
143     public static function get_aggregation_methods() {
144         return array(
145             COMPLETION_AGGREGATION_ALL       => get_string('all'),
146             COMPLETION_AGGREGATION_ANY       => get_string('any', 'completion'),
147         );
148     }
150     /**
151      * Constructs with course details.
152      *
153      * @param object $course Moodle course object. Must have at least ->id, ->enablecompletion
154      */
155     public function __construct($course) {
156         $this->course = $course;
157         $this->course_id = $course->id;
158     }
160     /**
161      * Determines whether completion is enabled across entire site.
162      *
163      * Static function.
164      *
165      * @global object
166      * @return int COMPLETION_ENABLED (true) if completion is enabled for the site,
167      *     COMPLETION_DISABLED (false) if it's complete
168      */
169     public static function is_enabled_for_site() {
170         global $CFG;
171         return !empty($CFG->enablecompletion);
172     }
174     /**
175      * Checks whether completion is enabled in a particular course and possibly
176      * activity.
177      *
178      * @global object
179      * @uses COMPLETION_DISABLED
180      * @uses COMPLETION_ENABLED
181      * @param object $cm Course-module object. If not specified, returns the course
182      *   completion enable state.
183      * @return mixed COMPLETION_ENABLED or COMPLETION_DISABLED (==0) in the case of
184      *   site and course; COMPLETION_TRACKING_MANUAL, _AUTOMATIC or _NONE (==0)
185      *   for a course-module.
186      */
187     public function is_enabled($cm=null) {
188         global $CFG;
190         // First check global completion
191         if (!isset($CFG->enablecompletion) || $CFG->enablecompletion == COMPLETION_DISABLED) {
192             return COMPLETION_DISABLED;
193         }
195         // Check course completion
196         if ($this->course->enablecompletion == COMPLETION_DISABLED) {
197             return COMPLETION_DISABLED;
198         }
200         // If there was no $cm and we got this far, then it's enabled
201         if (!$cm) {
202             return COMPLETION_ENABLED;
203         }
205         // Return course-module completion value
206         return $cm->completion;
207     }
209     /**
210      * Print the Your progress help icon if the completion tracking is enabled.
211      * @global object
212      * @return void
213      */
214     public function print_help_icon() {
215         global $PAGE, $OUTPUT;
216         if ($this->is_enabled() && !$PAGE->user_is_editing() && isloggedin() && !isguestuser()) {
217             echo '<span id = "completionprogressid" class="completionprogress">'.get_string('yourprogress','completion').' ';
218             echo $OUTPUT->help_icon('completionicons', 'completion');
219             echo '</span>';
220         }
221     }
223     /**
224      * Get a course completion for a user
225      * @access  public
226      * @param   $user_id        int     User id
227      * @param   $criteriatype   int     Specific criteria type to return
228      * @return  false|completion_criteria_completion
229      */
230     public function get_completion($user_id, $criteriatype) {
231         $completions = $this->get_completions($user_id, $criteriatype);
233         if (empty($completions)) {
234             return false;
235         } elseif (count($completions) > 1) {
236             print_error('multipleselfcompletioncriteria', 'completion');
237         }
239         return $completions[0];
240     }
242     /**
243      * Get all course criteria's completion objects for a user
244      * @access  public
245      * @param   $user_id        int     User id
246      * @param   $criteriatype   int     optional    Specific criteria type to return
247      * @return  array
248      */
249     public function get_completions($user_id, $criteriatype = null) {
250         $criterion = $this->get_criteria($criteriatype);
252         $completions = array();
254         foreach ($criterion as $criteria) {
255             $params = array(
256                 'course'        => $this->course_id,
257                 'userid'        => $user_id,
258                 'criteriaid'    => $criteria->id
259             );
261             $completion = new completion_criteria_completion($params);
262             $completion->attach_criteria($criteria);
264             $completions[] = $completion;
265         }
267         return $completions;
268     }
270     /**
271      * Get completion object for a user and a criteria
272      * @access  public
273      * @param   $user_id        int     User id
274      * @param   $criteria       completion_criteria     Criteria object
275      * @return  completion_criteria_completion
276      */
277     public function get_user_completion($user_id, $criteria) {
278         $params = array(
279             'criteriaid'    => $criteria->id,
280             'userid'        => $user_id
281         );
283         $completion = new completion_criteria_completion($params);
284         return $completion;
285     }
287     /**
288      * Check if course has completion criteria set
289      *
290      * @access  public
291      * @return  bool
292      */
293     public function has_criteria() {
294         $criteria = $this->get_criteria();
296         return (bool) count($criteria);
297     }
300     /**
301      * Get course completion criteria
302      * @access  public
303      * @param   $criteriatype   int     optional    Specific criteria type to return
304      * @return  void
305      */
306     public function get_criteria($criteriatype = null) {
308         // Fill cache if empty
309         if (!is_array($this->criteria)) {
310             global $DB;
312             $params = array(
313                 'course'    => $this->course->id
314             );
316             // Load criteria from database
317             $records = (array)$DB->get_records('course_completion_criteria', $params);
319             // Build array of criteria objects
320             $this->criteria = array();
321             foreach ($records as $record) {
322                 $this->criteria[$record->id] = completion_criteria::factory($record);
323             }
324         }
326         // If after all criteria
327         if ($criteriatype === null) {
328             return $this->criteria;
329         }
331         // If we are only after a specific criteria type
332         $criteria = array();
333         foreach ($this->criteria as $criterion) {
335             if ($criterion->criteriatype != $criteriatype) {
336                 continue;
337             }
339             $criteria[$criterion->id] = $criterion;
340         }
342         return $criteria;
343     }
345     /**
346      * Get aggregation method
347      * @access  public
348      * @param   $criteriatype   int     optional    If none supplied, get overall aggregation method
349      * @return  int
350      */
351     public function get_aggregation_method($criteriatype = null) {
352         $params = array(
353             'course'        => $this->course_id,
354             'criteriatype'  => $criteriatype
355         );
357         $aggregation = new completion_aggregation($params);
359         if (!$aggregation->id) {
360             $aggregation->method = COMPLETION_AGGREGATION_ALL;
361         }
363         return $aggregation->method;
364     }
366     /**
367      * Get incomplete course completion criteria
368      * @access  public
369      * @return  void
370      */
371     public function get_incomplete_criteria() {
372         $incomplete = array();
374         foreach ($this->get_criteria() as $criteria) {
375             if (!$criteria->is_complete()) {
376                 $incomplete[] = $criteria;
377             }
378         }
380         return $incomplete;
381     }
383     /**
384      * Clear old course completion criteria
385      */
386     public function clear_criteria() {
387         global $DB;
388         $DB->delete_records('course_completion_criteria', array('course' => $this->course_id));
389         $DB->delete_records('course_completion_aggr_methd', array('course' => $this->course_id));
391         $this->delete_course_completion_data();
392     }
394     /**
395      * Has the supplied user completed this course
396      * @access  public
397      * @param   $user_id    int     User's id
398      * @return  boolean
399      */
400     public function is_course_complete($user_id) {
401         $params = array(
402             'userid'    => $user_id,
403             'course'  => $this->course_id
404         );
406         $ccompletion = new completion_completion($params);
407         return $ccompletion->is_complete();
408     }
410     /**
411      * Updates (if necessary) the completion state of activity $cm for the given
412      * user.
413      *
414      * For manual completion, this function is called when completion is toggled
415      * with $possibleresult set to the target state.
416      *
417      * For automatic completion, this function should be called every time a module
418      * does something which might influence a user's completion state. For example,
419      * if a forum provides options for marking itself 'completed' once a user makes
420      * N posts, this function should be called every time a user makes a new post.
421      * [After the post has been saved to the database]. When calling, you do not
422      * need to pass in the new completion state. Instead this function carries out
423      * completion calculation by checking grades and viewed state itself, and
424      * calling the involved module via modulename_get_completion_state() to check
425      * module-specific conditions.
426      *
427      * @global object
428      * @global object
429      * @uses COMPLETION_COMPLETE
430      * @uses COMPLETION_INCOMPLETE
431      * @uses COMPLETION_COMPLETE_PASS
432      * @uses COMPLETION_COMPLETE_FAIL
433      * @uses COMPLETION_TRACKING_MANUAL
434      * @param object $cm Course-module
435      * @param int $possibleresult Expected completion result. If the event that
436      *   has just occurred (e.g. add post) can only result in making the activity
437      *   complete when it wasn't before, use COMPLETION_COMPLETE. If the event that
438      *   has just occurred (e.g. delete post) can only result in making the activity
439      *   not complete when it was previously complete, use COMPLETION_INCOMPLETE.
440      *   Otherwise use COMPLETION_UNKNOWN. Setting this value to something other than
441      *   COMPLETION_UNKNOWN significantly improves performance because it will abandon
442      *   processing early if the user's completion state already matches the expected
443      *   result. For manual events, COMPLETION_COMPLETE or COMPLETION_INCOMPLETE
444      *   must be used; these directly set the specified state.
445      * @param int $userid User ID to be updated. Default 0 = current user
446      * @return void
447      */
448     public function update_state($cm, $possibleresult=COMPLETION_UNKNOWN, $userid=0) {
449         global $USER, $SESSION;
451         // Do nothing if completion is not enabled for that activity
452         if (!$this->is_enabled($cm)) {
453             return;
454         }
456         // Get current value of completion state and do nothing if it's same as
457         // the possible result of this change. If the change is to COMPLETE and the
458         // current value is one of the COMPLETE_xx subtypes, ignore that as well
459         $current = $this->get_data($cm, false, $userid);
460         if ($possibleresult == $current->completionstate ||
461             ($possibleresult == COMPLETION_COMPLETE &&
462                 ($current->completionstate == COMPLETION_COMPLETE_PASS ||
463                 $current->completionstate == COMPLETION_COMPLETE_FAIL))) {
464             return;
465         }
467         if ($cm->completion == COMPLETION_TRACKING_MANUAL) {
468             // For manual tracking we set the result directly
469             switch($possibleresult) {
470                 case COMPLETION_COMPLETE:
471                 case COMPLETION_INCOMPLETE:
472                     $newstate = $possibleresult;
473                     break;
474                 default:
475                     $this->internal_systemerror("Unexpected manual completion state for {$cm->id}: $possibleresult");
476             }
478         } else {
479             // Automatic tracking; get new state
480             $newstate = $this->internal_get_state($cm, $userid, $current);
481         }
483         // If changed, update
484         if ($newstate != $current->completionstate) {
485             $current->completionstate = $newstate;
486             $current->timemodified    = time();
487             $this->internal_set_data($cm, $current);
488         }
489     }
491     /**
492      * Calculates the completion state for an activity and user.
493      *
494      * Internal function. Not private, so we can unit-test it.
495      *
496      * @global object
497      * @global object
498      * @global object
499      * @uses COMPLETION_VIEW_REQUIRED
500      * @uses COMPLETION_NOT_VIEWED
501      * @uses COMPLETION_INCOMPLETE
502      * @uses FEATURE_COMPLETION_HAS_RULES
503      * @uses COMPLETION_COMPLETE
504      * @uses COMPLETION_AND
505      * @param object $cm Activity
506      * @param int $userid ID of user
507      * @param object $current Previous completion information from database
508      * @return mixed
509      */
510     function internal_get_state($cm, $userid, $current) {
511         global $USER, $DB, $CFG;
513         // Get user ID
514         if (!$userid) {
515             $userid = $USER->id;
516         }
518         // Check viewed
519         if ($cm->completionview == COMPLETION_VIEW_REQUIRED &&
520             $current->viewed == COMPLETION_NOT_VIEWED) {
522             return COMPLETION_INCOMPLETE;
523         }
525         // Modname hopefully is provided in $cm but just in case it isn't, let's grab it
526         if (!isset($cm->modname)) {
527             $cm->modname = $DB->get_field('modules', 'name', array('id'=>$cm->module));
528         }
530         $newstate = COMPLETION_COMPLETE;
532         // Check grade
533         if (!is_null($cm->completiongradeitemnumber)) {
534             require_once($CFG->libdir.'/gradelib.php');
535             $item = grade_item::fetch(array('courseid'=>$cm->course, 'itemtype'=>'mod',
536                 'itemmodule'=>$cm->modname, 'iteminstance'=>$cm->instance,
537                 'itemnumber'=>$cm->completiongradeitemnumber));
538             if ($item) {
539                 // Fetch 'grades' (will be one or none)
540                 $grades = grade_grade::fetch_users_grades($item, array($userid), false);
541                 if (empty($grades)) {
542                     // No grade for user
543                     return COMPLETION_INCOMPLETE;
544                 }
545                 if (count($grades) > 1) {
546                     $this->internal_systemerror("Unexpected result: multiple grades for
547                         item '{$item->id}', user '{$userid}'");
548                 }
549                 $newstate = $this->internal_get_grade_state($item, reset($grades));
550                 if ($newstate == COMPLETION_INCOMPLETE) {
551                     return COMPLETION_INCOMPLETE;
552                 }
554             } else {
555                 $this->internal_systemerror("Cannot find grade item for '{$cm->modname}'
556                     cm '{$cm->id}' matching number '{$cm->completiongradeitemnumber}'");
557             }
558         }
560         if (plugin_supports('mod', $cm->modname, FEATURE_COMPLETION_HAS_RULES)) {
561             $function = $cm->modname.'_get_completion_state';
562             if (!function_exists($function)) {
563                 $this->internal_systemerror("Module {$cm->modname} claims to support
564                     FEATURE_COMPLETION_HAS_RULES but does not have required
565                     {$cm->modname}_get_completion_state function");
566             }
567             if (!$function($this->course, $cm, $userid, COMPLETION_AND)) {
568                 return COMPLETION_INCOMPLETE;
569             }
570         }
572         return $newstate;
574     }
577     /**
578      * Marks a module as viewed.
579      *
580      * Should be called whenever a module is 'viewed' (it is up to the module how to
581      * determine that). Has no effect if viewing is not set as a completion condition.
582      *
583      * @uses COMPLETION_VIEW_NOT_REQUIRED
584      * @uses COMPLETION_VIEWED
585      * @uses COMPLETION_COMPLETE
586      * @param object $cm Activity
587      * @param int $userid User ID or 0 (default) for current user
588      * @return void
589      */
590     public function set_module_viewed($cm, $userid=0) {
591         // Don't do anything if view condition is not turned on
592         if ($cm->completionview == COMPLETION_VIEW_NOT_REQUIRED || !$this->is_enabled($cm)) {
593             return;
594         }
595         // Get current completion state
596         $data = $this->get_data($cm, $userid);
597         // If we already viewed it, don't do anything
598         if ($data->viewed == COMPLETION_VIEWED) {
599             return;
600         }
601         // OK, change state, save it, and update completion
602         $data->viewed = COMPLETION_VIEWED;
603         $this->internal_set_data($cm, $data);
604         $this->update_state($cm, COMPLETION_COMPLETE, $userid);
605     }
607     /**
608      * Determines how much completion data exists for an activity. This is used when
609      * deciding whether completion information should be 'locked' in the module
610      * editing form.
611      *
612      * @global object
613      * @param object $cm Activity
614      * @return int The number of users who have completion data stored for this
615      *   activity, 0 if none
616      */
617     public function count_user_data($cm) {
618         global $DB;
620         return $DB->get_field_sql("
621     SELECT
622         COUNT(1)
623     FROM
624         {course_modules_completion}
625     WHERE
626         coursemoduleid=? AND completionstate<>0", array($cm->id));
627     }
629     /**
630      * Determines how much course completion data exists for a course. This is used when
631      * deciding whether completion information should be 'locked' in the completion
632      * settings form and activity completion settings.
633      *
634      * @global object
635      * @param  int $user_id Optionally only get course completion data for a single user
636      * @return int The number of users who have completion data stored for this
637      *   course, 0 if none
638      */
639     public function count_course_user_data($user_id = null) {
640         global $DB;
642         $sql = '
643     SELECT
644         COUNT(1)
645     FROM
646         {course_completion_crit_compl}
647     WHERE
648         course = ?
649         ';
651         $params = array($this->course_id);
653         // Limit data to a single user if an ID is supplied
654         if ($user_id) {
655             $sql .= ' AND userid = ?';
656             $params[] = $user_id;
657         }
659         return $DB->get_field_sql($sql, $params);
660     }
662     /**
663      * Check if this course's completion criteria should be locked
664      *
665      * @return  boolean
666      */
667     public function is_course_locked() {
668         return (bool) $this->count_course_user_data();
669     }
671     /**
672      * Deletes all course completion completion data.
673      *
674      * Intended to be used when unlocking completion criteria settings.
675      *
676      * @global  object
677      * @return  void
678      */
679     public function delete_course_completion_data() {
680         global $DB;
682         $DB->delete_records('course_completions', array('course' => $this->course_id));
683         $DB->delete_records('course_completion_crit_compl', array('course' => $this->course_id));
684     }
686     /**
687      * Deletes completion state related to an activity for all users.
688      *
689      * Intended for use only when the activity itself is deleted.
690      *
691      * @global object
692      * @global object
693      * @param object $cm Activity
694      */
695     public function delete_all_state($cm) {
696         global $SESSION, $DB;
698         // Delete from database
699         $DB->delete_records('course_modules_completion', array('coursemoduleid'=>$cm->id));
701         // Erase cache data for current user if applicable
702         if (isset($SESSION->completioncache) &&
703             array_key_exists($cm->course, $SESSION->completioncache) &&
704             array_key_exists($cm->id, $SESSION->completioncache[$cm->course])) {
706             unset($SESSION->completioncache[$cm->course][$cm->id]);
707         }
709         // Check if there is an associated course completion criteria
710         $criteria = $this->get_criteria(COMPLETION_CRITERIA_TYPE_ACTIVITY);
711         $acriteria = false;
712         foreach ($criteria as $criterion) {
713             if ($criterion->moduleinstance == $cm->id) {
714                 $acriteria = $criterion;
715                 break;
716     }
717         }
719         if ($acriteria) {
720             // Delete all criteria completions relating to this activity
721             $DB->delete_records('course_completion_crit_compl', array('course' => $this->course_id, 'criteriaid' => $acriteria->id));
722             $DB->delete_records('course_completions', array('course' => $this->course_id));
723         }
724     }
726     /**
727      * Recalculates completion state related to an activity for all users.
728      *
729      * Intended for use if completion conditions change. (This should be avoided
730      * as it may cause some things to become incomplete when they were previously
731      * complete, with the effect - for example - of hiding a later activity that
732      * was previously available.)
733      *
734      * Resetting state of manual tickbox has same result as deleting state for
735      * it.
736      *
737      * @global object
738      * @uses COMPLETION_TRACKING_MANUAL
739      * @uses COMPLETION_UNKNOWN
740      * @param object $cm Activity
741      */
742     public function reset_all_state($cm) {
743         global $DB;
745         if ($cm->completion == COMPLETION_TRACKING_MANUAL) {
746             $this->delete_all_state($cm);
747             return;
748         }
749         // Get current list of users with completion state
750         $rs = $DB->get_recordset('course_modules_completion', array('coursemoduleid'=>$cm->id), '', 'userid');
751         $keepusers = array();
752         foreach ($rs as $rec) {
753             $keepusers[] = $rec->userid;
754         }
755         $rs->close();
757         // Delete all existing state [also clears session cache for current user]
758         $this->delete_all_state($cm);
760         // Merge this with list of planned users (according to roles)
761         $trackedusers = $this->get_tracked_users();
762         foreach ($trackedusers as $trackeduser) {
763             $keepusers[] = $trackeduser->id;
764         }
765         $keepusers = array_unique($keepusers);
767         // Recalculate state for each kept user
768         foreach ($keepusers as $keepuser) {
769             $this->update_state($cm, COMPLETION_UNKNOWN, $keepuser);
770         }
771     }
773     /**
774      * Obtains completion data for a particular activity and user (from the
775      * session cache if available, or by SQL query)
776      *
777      * @global object
778      * @global object
779      * @global object
780      * @global object
781      * @uses COMPLETION_CACHE_EXPIRY
782      * @param object $cm Activity; only required field is ->id
783      * @param bool $wholecourse If true (default false) then, when necessary to
784      *   fill the cache, retrieves information from the entire course not just for
785      *   this one activity
786      * @param int $userid User ID or 0 (default) for current user
787      * @param array $modinfo Supply the value here - this is used for unit
788      *   testing and so that it can be called recursively from within
789      *   get_fast_modinfo. (Needs only list of all CMs with IDs.)
790      *   Otherwise the method calls get_fast_modinfo itself.
791      * @return object Completion data (record from course_modules_completion)
792      */
793     public function get_data($cm, $wholecourse=false, $userid=0, $modinfo=null) {
794         global $USER, $CFG, $SESSION, $DB;
796         // Get user ID
797         if (!$userid) {
798             $userid = $USER->id;
799         }
801         // Is this the current user?
802         $currentuser = $userid==$USER->id;
804         if ($currentuser && is_object($SESSION)) {
805             // Make sure cache is present and is for current user (loginas
806             // changes this)
807             if (!isset($SESSION->completioncache) || $SESSION->completioncacheuserid!=$USER->id) {
808                 $SESSION->completioncache = array();
809                 $SESSION->completioncacheuserid = $USER->id;
810             }
811             // Expire any old data from cache
812             foreach ($SESSION->completioncache as $courseid=>$activities) {
813                 if (empty($activities['updated']) || $activities['updated'] < time()-COMPLETION_CACHE_EXPIRY) {
814                     unset($SESSION->completioncache[$courseid]);
815                 }
816             }
817             // See if requested data is present, if so use cache to get it
818             if (isset($SESSION->completioncache) &&
819                 array_key_exists($this->course->id, $SESSION->completioncache) &&
820                 array_key_exists($cm->id, $SESSION->completioncache[$this->course->id])) {
821                 return $SESSION->completioncache[$this->course->id][$cm->id];
822             }
823         }
825         // Not there, get via SQL
826         if ($currentuser && $wholecourse) {
827             // Get whole course data for cache
828             $alldatabycmc = $DB->get_records_sql("
829     SELECT
830         cmc.*
831     FROM
832         {course_modules} cm
833         INNER JOIN {course_modules_completion} cmc ON cmc.coursemoduleid=cm.id
834     WHERE
835         cm.course=? AND cmc.userid=?", array($this->course->id, $userid));
837             // Reindex by cm id
838             $alldata = array();
839             if ($alldatabycmc) {
840                 foreach ($alldatabycmc as $data) {
841                     $alldata[$data->coursemoduleid] = $data;
842                 }
843             }
845             // Get the module info and build up condition info for each one
846             if (empty($modinfo)) {
847                 $modinfo = get_fast_modinfo($this->course, $userid);
848             }
849             foreach ($modinfo->cms as $othercm) {
850                 if (array_key_exists($othercm->id, $alldata)) {
851                     $data = $alldata[$othercm->id];
852                 } else {
853                     // Row not present counts as 'not complete'
854                     $data = new StdClass;
855                     $data->id              = 0;
856                     $data->coursemoduleid  = $othercm->id;
857                     $data->userid          = $userid;
858                     $data->completionstate = 0;
859                     $data->viewed          = 0;
860                     $data->timemodified    = 0;
861                 }
862                 $SESSION->completioncache[$this->course->id][$othercm->id] = $data;
863             }
864             $SESSION->completioncache[$this->course->id]['updated'] = time();
866             if (!isset($SESSION->completioncache[$this->course->id][$cm->id])) {
867                 $this->internal_systemerror("Unexpected error: course-module {$cm->id} could not be found on course {$this->course->id}");
868             }
869             return $SESSION->completioncache[$this->course->id][$cm->id];
871         } else {
872             // Get single record
873             $data = $DB->get_record('course_modules_completion', array('coursemoduleid'=>$cm->id, 'userid'=>$userid));
874             if ($data == false) {
875                 // Row not present counts as 'not complete'
876                 $data = new StdClass;
877                 $data->id              = 0;
878                 $data->coursemoduleid  = $cm->id;
879                 $data->userid          = $userid;
880                 $data->completionstate = 0;
881                 $data->viewed          = 0;
882                 $data->timemodified    = 0;
883             }
885             // Put in cache
886             if ($currentuser) {
887                 $SESSION->completioncache[$this->course->id][$cm->id] = $data;
888                 // For single updates, only set date if it was empty before
889                 if (empty($SESSION->completioncache[$this->course->id]['updated'])) {
890                     $SESSION->completioncache[$this->course->id]['updated'] = time();
891                 }
892             }
893         }
895         return $data;
896     }
898     /**
899      * Updates completion data for a particular coursemodule and user (user is
900      * determined from $data).
901      *
902      * (Internal function. Not private, so we can unit-test it.)
903      *
904      * @global object
905      * @global object
906      * @global object
907      * @param object $cm Activity
908      * @param object $data Data about completion for that user
909      */
910     function internal_set_data($cm, $data) {
911         global $USER, $SESSION, $DB;
913         if ($data->id) {
914             // Has real (nonzero) id meaning that a database row exists
915             $DB->update_record('course_modules_completion', $data);
916         } else {
917             // Didn't exist before, needs creating
918             $data->id = $DB->insert_record('course_modules_completion', $data);
919         }
921         if ($data->userid == $USER->id) {
922             $SESSION->completioncache[$cm->course][$cm->id] = $data;
923         }
924     }
926     /**
927      * Obtains a list of activities for which completion is enabled on the
928      * course. The list is ordered by the section order of those activities.
929      *
930      * @global object
931      * @uses COMPLETION_TRACKING_NONE
932      * @param array $modinfo For unit testing only, supply the value
933      *   here. Otherwise the method calls get_fast_modinfo
934      * @return array Array from $cmid => $cm of all activities with completion enabled,
935      *   empty array if none
936      */
937     public function get_activities($modinfo=null) {
938         global $DB;
940         // Obtain those activities which have completion turned on
941         $withcompletion = $DB->get_records_select('course_modules', 'course='.$this->course->id.
942           ' AND completion<>'.COMPLETION_TRACKING_NONE);
943         if (!$withcompletion) {
944             return array();
945         }
947         // Use modinfo to get section order and also add in names
948         if (empty($modinfo)) {
949             $modinfo = get_fast_modinfo($this->course);
950         }
951         $result = array();
952         foreach ($modinfo->sections as $sectioncms) {
953             foreach ($sectioncms as $cmid) {
954                 if (array_key_exists($cmid, $withcompletion)) {
955                     $result[$cmid] = $withcompletion[$cmid];
956                     $result[$cmid]->modname = $modinfo->cms[$cmid]->modname;
957                     $result[$cmid]->name    = $modinfo->cms[$cmid]->name;
958                 }
959             }
960         }
962         return $result;
963     }
966     /**
967      * Checks to see if the userid supplied has a tracked role in
968      * this course
969      *
970      * @param   $userid     User id
971      * @return  bool
972      */
973     function is_tracked_user($userid) {
974         global $DB;
976         $tracked = $this->generate_tracked_user_sql();
978         $sql  = "SELECT u.id ";
979         $sql .= $tracked->sql;
980         $sql .= ' AND u.id = :user';
982         $params = $tracked->data;
983         $params['user'] = (int)$userid;
984         return $DB->record_exists_sql($sql, $params);
985     }
988     /**
989      * Return number of users whose progress is tracked in this course
990      *
991      * Optionally supply a search's where clause, or a group id
992      *
993      * @param   string  $where          Where clause sql
994      * @param   array   $where_params   Where clause params
995      * @param   int     $groupid    Group id
996      * @return  int
997      */
998     function get_num_tracked_users($where = '', $where_params = array(), $groupid = 0) {
999         global $DB;
1001         $tracked = $this->generate_tracked_user_sql($groupid);
1003         $sql  = "SELECT COUNT(u.id) ";
1004         $sql .= $tracked->sql;
1006         if ($where) {
1007             $sql .= " AND $where";
1008         }
1010         $params = array_merge($tracked->data, $where_params);
1011         return $DB->count_records_sql($sql, $params);
1012     }
1015     /**
1016      * Return array of users whose progress is tracked in this course
1017      *
1018      * Optionally supply a search's where caluse, group id, sorting, paging
1019      *
1020      * @param   string      $where          Where clause sql (optional)
1021      * @param   array       $where_params   Where clause params (optional)
1022      * @param   integer     $groupid    Group ID to restrict to (optional)
1023      * @param   string      $sort       Order by clause (optional)
1024      * @param   integer     $limitfrom  Result start (optional)
1025      * @param   integer     $limitnum   Result max size (optional)
1026      * @return  array
1027      */
1028     function get_tracked_users($where = '', $where_params = array(), $groupid = 0,
1029              $sort = '', $limitfrom = '', $limitnum = '') {
1031         global $DB;
1033         $tracked = $this->generate_tracked_user_sql($groupid);
1034         $params = $tracked->data;
1036         $sql = "
1037             SELECT
1038                 u.id,
1039                 u.firstname,
1040                 u.lastname,
1041                 u.idnumber
1042         ";
1044         $sql .= $tracked->sql;
1046         if ($where) {
1047             $sql .= " AND $where";
1048             $params = array_merge($params, $where_params);
1049         }
1051         if ($sort) {
1052             $sql .= " ORDER BY $sort";
1053         }
1055         $users = $DB->get_records_sql($sql, $params, $limitfrom, $limitnum);
1056         return $users ? $users : array(); // In case it returns false
1057     }
1060     /**
1061      * Generate the SQL for finding tracked users in this course
1062      *
1063      * Returns an object containing the sql fragment and an array of
1064      * bound data params.
1065      *
1066      * @param   integer $groupid
1067      * @return  object
1068      */
1069     function generate_tracked_user_sql($groupid = 0) {
1070         global $CFG;
1072         $return = new stdClass();
1073         $return->sql = '';
1074         $return->data = array();
1076         if (!empty($CFG->progresstrackedroles)) {
1077             $roles = ' AND ra.roleid IN ('.$CFG->progresstrackedroles.')';
1078         } else {
1079             // This causes it to default to everyone (if there is no student role)
1080             $roles = '';
1081         }
1083         // Build context sql
1084         $context = get_context_instance(CONTEXT_COURSE, $this->course->id);
1085         $parentcontexts = substr($context->path, 1); // kill leading slash
1086         $parentcontexts = str_replace('/', ',', $parentcontexts);
1087         if ($parentcontexts !== '') {
1088             $parentcontexts = ' OR ra.contextid IN ('.$parentcontexts.' )';
1089         }
1091         $groupjoin   = '';
1092         $groupselect = '';
1093         if ($groupid) {
1094             $groupjoin   = "JOIN {groups_members} gm
1095                               ON gm.userid = u.id";
1096             $groupselect = " AND gm.groupid = :groupid ";
1097             
1098             $return->data['groupid'] = $groupid;
1099         }
1101         $return->sql = "
1102             FROM
1103                 {user} u
1104             INNER JOIN
1105                 {role_assignments} ra
1106              ON ra.userid = u.id
1107             INNER JOIN
1108                 {role} r
1109              ON r.id = ra.roleid
1110             INNER JOIN
1111                 {user_enrolments} ue
1112              ON ue.userid = u.id
1113             INNER JOIN
1114                 {enrol} e
1115              ON e.id = ue.enrolid
1116             INNER JOIN
1117                 {course} c
1118              ON c.id = e.courseid
1119             $groupjoin
1120             WHERE
1121                 (ra.contextid = :contextid $parentcontexts)
1122             AND c.id = :courseid
1123             AND ue.status = 0
1124             AND e.status = 0
1125             AND ue.timestart < :now1
1126             AND (ue.timeend > :now2 OR ue.timeend = 0)
1127                 $groupselect
1128                 $roles
1129         ";
1131         $now = time();
1132         $return->data['now1'] = $now;
1133         $return->data['now2'] = $now;
1134         $return->data['contextid'] = $context->id;
1135         $return->data['courseid'] = $this->course->id;
1137         return $return;
1138     }
1140     /**
1141      * Obtains progress information across a course for all users on that course, or
1142      * for all users in a specific group. Intended for use when displaying progress.
1143      *
1144      * This includes only users who, in course context, have one of the roles for
1145      * which progress is tracked (the progresstrackedroles admin option).
1146      *
1147      * Users are included (in the first array) even if they do not have
1148      * completion progress for any course-module.
1149      *
1150      * @global object
1151      * @global object
1152      * @param bool $sortfirstname If true, sort by first name, otherwise sort by
1153      *   last name
1154      * @param string $where Where clause sql (optional)
1155      * @param array $where_params Where clause params (optional)
1156      * @param int $groupid Group ID or 0 (default)/false for all groups
1157      * @param int $pagesize Number of users to actually return (optional)
1158      * @param int $start User to start at if paging (optional)
1159      * @return Object with ->total and ->start (same as $start) and ->users;
1160      *   an array of user objects (like mdl_user id, firstname, lastname)
1161      *   containing an additional ->progress array of coursemoduleid => completionstate
1162      */
1163     public function get_progress_all($where = '', $where_params = array(), $groupid = 0,
1164                                        $sort = '', $pagesize = '', $start = '') {
1165         global $CFG, $DB;
1167         // Get list of applicable users
1168         $users = $this->get_tracked_users($where, $where_params, $groupid, $sort, $start, $pagesize);
1170         // Get progress information for these users in groups of 1, 000 (if needed)
1171         // to avoid making the SQL IN too long
1172         $results = array();
1173         $userids = array();
1174         foreach ($users as $user) {
1175             $userids[] = $user->id;
1176             $results[$user->id] = $user;
1177             $results[$user->id]->progress = array();
1178         }
1180         for($i=0; $i<count($userids); $i+=1000) {
1181             $blocksize = count($userids)-$i < 1000 ? count($userids)-$i : 1000;
1183             list($insql, $params) = $DB->get_in_or_equal(array_slice($userids, $i, $blocksize));
1184             array_splice($params, 0, 0, array($this->course->id));
1185             $rs = $DB->get_recordset_sql("
1186                 SELECT
1187                     cmc.*
1188                 FROM
1189                     {course_modules} cm
1190                     INNER JOIN {course_modules_completion} cmc ON cm.id=cmc.coursemoduleid
1191                 WHERE
1192                     cm.course=? AND cmc.userid $insql
1193     ", $params);
1194             foreach ($rs as $progress) {
1195                 $progress = (object)$progress;
1196                 $results[$progress->userid]->progress[$progress->coursemoduleid] = $progress;
1197             }
1198             $rs->close();
1199         }
1201         return $results;
1202     }
1204     /**
1205      * Called by grade code to inform the completion system when a grade has
1206      * been changed. If the changed grade is used to determine completion for
1207      * the course-module, then the completion status will be updated.
1208      *
1209      * @uses COMPLETION_TRACKING_MANUAL
1210      * @uses COMPLETION_INCOMPLETE
1211      * @param object $cm Course-module for item that owns grade
1212      * @param grade_item $item Grade item
1213      * @param object $grade
1214      * @param bool $deleted
1215      * @return void
1216      */
1217     public function inform_grade_changed($cm, $item, $grade, $deleted) {
1218         // Bail out now if completion is not enabled for course-module, it is enabled
1219         // but is set to manual, grade is not used to compute completion, or this
1220         // is a different numbered grade
1221         if (!$this->is_enabled($cm) ||
1222             $cm->completion == COMPLETION_TRACKING_MANUAL ||
1223             is_null($cm->completiongradeitemnumber) ||
1224             $item->itemnumber != $cm->completiongradeitemnumber) {
1225             return;
1226         }
1228         // What is the expected result based on this grade?
1229         if ($deleted) {
1230             // Grade being deleted, so only change could be to make it incomplete
1231             $possibleresult = COMPLETION_INCOMPLETE;
1232         } else {
1233             $possibleresult = $this->internal_get_grade_state($item, $grade);
1234         }
1235         
1236         // OK, let's update state based on this
1237         $this->update_state($cm, $possibleresult, $grade->userid);
1238     }
1240     /**
1241      * Calculates the completion state that would result from a graded item
1242      * (where grade-based completion is turned on) based on the actual grade
1243      * and settings.
1244      *
1245      * Internal function. Not private, so we can unit-test it.
1246      *
1247      * @uses COMPLETION_INCOMPLETE
1248      * @uses COMPLETION_COMPLETE_PASS
1249      * @uses COMPLETION_COMPLETE_FAIL
1250      * @uses COMPLETION_COMPLETE
1251      * @param object $item grade_item
1252      * @param object $grade grade_grade
1253      * @return int Completion state e.g. COMPLETION_INCOMPLETE
1254      */
1255     function internal_get_grade_state($item, $grade) {
1256         if (!$grade) {
1257             return COMPLETION_INCOMPLETE;
1258         }
1259         // Conditions to show pass/fail:
1260         // a) Grade has pass mark (default is 0.00000 which is boolean true so be careful)
1261         // b) Grade is visible (neither hidden nor hidden-until)
1262         if ($item->gradepass && $item->gradepass > 0.000009 && !$item->hidden) {
1263             // Use final grade if set otherwise raw grade
1264             $score = !is_null($grade->finalgrade) ? $grade->finalgrade : $grade->rawgrade;
1266             // We are displaying and tracking pass/fail
1267             if ($score >= $item->gradepass) {
1268                 return COMPLETION_COMPLETE_PASS;
1269             } else {
1270                 return COMPLETION_COMPLETE_FAIL;
1271             }
1272         } else {
1273             // Not displaying pass/fail, so just if there is a grade
1274             if (!is_null($grade->finalgrade) || !is_null($grade->rawgrade)) {
1275                 // Grade exists, so maybe complete now
1276                 return COMPLETION_COMPLETE;
1277             } else {
1278                 // Grade does not exist, so maybe incomplete now
1279                 return COMPLETION_INCOMPLETE;
1280             }
1281         }
1282     }
1284     /**
1285      * This is to be used only for system errors (things that shouldn't happen)
1286      * and not user-level errors.
1287      *
1288      * @global object
1289      * @param string $error Error string (will not be displayed to user unless
1290      *   debugging is enabled)
1291      * @return void Throws moodle_exception Exception with the error string as debug info
1292      */
1293     function internal_systemerror($error) {
1294         global $CFG;
1295         throw new moodle_exception('err_system','completion',
1296             $CFG->wwwroot.'/course/view.php?id='.$this->course->id,null,$error);
1297     }
1299     /**
1300      * For testing only. Wipes information cached in user session.
1301      *
1302      * @global object
1303      */
1304     static function wipe_session_cache() {
1305         global $SESSION;
1306         unset($SESSION->completioncache);
1307         unset($SESSION->completioncacheuserid);
1308     }