| ' . get_string('directory') . " | \n" . '' . get_string('name') . " | \n" . '' . get_string('status') . " | \n? | \n" . "$plugin_name | \n" . "" . ${'str' . $standard} . " $strmissingfromdisk | \n\n"; - $row++; + $row++; } } $html .= '
|---|
+ * Just add elements to the form as needed and return the list of IDs. The + * system will call disabledIf and handle other behaviour for each returned + * ID. + * @return array Array of string IDs of added items, empty array if none + */ + function add_completion_rules() { + return array(); + } + + /** + * Called during validation. Override to indicate, based on the data, whether + * a custom completion rule is enabled (selected). + * + * @param array $data Input data (not yet validated) + * @return bool True if one or more rules is enabled, false if none are; + * default returns false + */ + function completion_rule_enabled(&$data) { + return false; + } function standard_hidden_coursemodule_elements(){ $mform =& $this->_form; Index: course/view.php =================================================================== RCS file: /cvsroot/moodle/moodle/course/view.php,v retrieving revision 1.113 diff -u -r1.113 view.php --- course/view.php 2 Jun 2008 08:13:25 -0000 1.113 +++ course/view.php 28 Jul 2008 12:05:04 -0000 @@ -195,6 +195,15 @@ $PAGE->print_header(get_string('course').': %fullname%', NULL, '', $bodytags); + $completion=new completion_info($course); + if($completion->is_enabled() && ajaxenabled()) { + require_js(array('yui_yahoo','yui_event','yui_connection','yui_dom')); + // Need to do this after the header because it requires the YUI stuff + // to be loaded already + print ''. + ''; + } + // Course wrapper start. echo '
Can't find new ID for cm $data->coursemoduleid.
"; + } + $status=false; + continue; + } + + // Convert userid + $newuserid=backup_getid($restore->backup_unique_code, 'user', $data->userid); + if($newuserid) { + $data->userid=$newuserid->new_id; + } else { + // Skip missing users + debugging("Not restoring completion data for missing user {$data->userid}",DEBUG_DEVELOPER); + continue; + } + + // Add record + if(!$DB->insert_record('course_modules_completion',$data)) { + if (!defined('RESTORE_SILENTLY')) { + echo "Failed to insert completion data record.
"; + } + $status=false; + continue; + } + } + } + } else { $status = false; } @@ -5164,6 +5208,9 @@ case "ENROLPERIOD": $this->info->course_enrolperiod = $this->getContents(); break; + case "ENABLECOMPLETION": + $this->info->course_enablecompletion = $this->getContents(); + break; } } if ($this->tree[4] == "CATEGORY") { @@ -5539,7 +5586,15 @@ $this->info->tempmod->groupmembersonly; $this->info->tempsection->mods[$this->info->tempmod->id]->idnumber = $this->info->tempmod->idnumber; - + $this->info->tempsection->mods[$this->info->tempmod->id]->completion = + isset($this->info->tempmod->completion) ? $this->info->tempmod->completion : 0; + $this->info->tempsection->mods[$this->info->tempmod->id]->completiongradeitemnumber = + isset($this->info->tempmod->completiongradeitemnumber) ? $this->info->tempmod->completiongradeitemnumber : null; + $this->info->tempsection->mods[$this->info->tempmod->id]->completionview = + isset($this->info->tempmod->completionview) ? $this->info->tempmod->completionview : 0; + $this->info->tempsection->mods[$this->info->tempmod->id]->completionexpected = + isset($this->info->tempmod->completionexpected) ? $this->info->tempmod->completionexpected : 0; + unset($this->info->tempmod); } } @@ -5577,6 +5632,18 @@ case "IDNUMBER": $this->info->tempmod->idnumber = $this->getContents(); break; + case "COMPLETION": + $this->info->tempmod->completion = $this->getContents(); + break; + case "COMPLETIONGRADEITEMNUMBER": + $this->info->tempmod->completiongradeitemnumber = $this->getContents(); + break; + case "COMPLETIONVIEW": + $this->info->tempmod->completionview = $this->getContents(); + break; + case "COMPLETIONEXPECTED": + $this->info->tempmod->completionexpected = $this->getContents(); + break; default: break; } @@ -5672,6 +5739,36 @@ } } /// ends role_overrides + if (isset($this->tree[7]) && $this->tree[7] == "COMPLETIONDATA") { + if($this->level == 8) { + switch($tagName) { + case 'COMPLETION': + // Got all data to make completion entry... + $this->info->tempcompletion->coursemoduleid=$this->info->tempmod->id; + $this->info->completiondata[]=$this->info->tempcompletion; + unset($this->info->tempcompletion); + $this->info->tempcompletion=new stdClass; + break; + } + } + + if($this->level == 9) { + switch($tagName) { + case 'USERID' : + $this->info->tempcompletion->userid=$this->getContents(); + break; + case 'COMPLETIONSTATE' : + $this->info->tempcompletion->completionstate=$this->getContents(); + break; + case 'VIEWED' : + $this->info->tempcompletion->viewed=$this->getContents(); + break; + case 'TIMEMODIFIED' : + $this->info->tempcompletion->timemodified=$this->getContents(); + break; + } + } + } } //Stop parsing if todo = SECTIONS and tagName = SECTIONS (en of the tag, of course) @@ -8338,22 +8435,22 @@ } /// Now, restore role nameincourse (only if the role had nameincourse in backup) if (!empty($roledata->nameincourse)) { - $newrole = backup_getid($restore->backup_unique_code, 'role', $oldroleid); /// Look for target role - $coursecontext = get_context_instance(CONTEXT_COURSE, $restore->course_id); /// Look for target context - if (!empty($newrole->new_id) && !empty($coursecontext) && !empty($roledata->nameincourse)) { - /// Check the role hasn't any custom name in context - if (!$DB->record_exists('role_names', array('roleid'=>$newrole->new_id, 'contextid'=>$coursecontext->id))) { - $rolename = new object(); - $rolename->roleid = $newrole->new_id; - $rolename->contextid = $coursecontext->id; - $rolename->name = $roledata->nameincourse; + $newrole = backup_getid($restore->backup_unique_code, 'role', $oldroleid); /// Look for target role + $coursecontext = get_context_instance(CONTEXT_COURSE, $restore->course_id); /// Look for target context + if (!empty($newrole->new_id) && !empty($coursecontext) && !empty($roledata->nameincourse)) { + /// Check the role hasn't any custom name in context + if (!$DB->record_exists('role_names', array('roleid'=>$newrole->new_id, 'contextid'=>$coursecontext->id))) { + $rolename = new object(); + $rolename->roleid = $newrole->new_id; + $rolename->contextid = $coursecontext->id; + $rolename->name = $roledata->nameincourse; - $DB->insert_record('role_names', $rolename); - } + $DB->insert_record('role_names', $rolename); } } } } + } return true; } Index: backup/backuplib.php =================================================================== RCS file: /cvsroot/moodle/moodle/backup/backuplib.php,v retrieving revision 1.215 diff -u -r1.215 backuplib.php --- backup/backuplib.php 2 Jul 2008 22:47:57 -0000 1.215 +++ backup/backuplib.php 28 Jul 2008 12:05:01 -0000 @@ -716,7 +716,8 @@ fwrite ($bf,full_tag("ENROLSTARTDATE",3,false,$course->enrolstartdate)); fwrite ($bf,full_tag("ENROLENDDATE",3,false,$course->enrolenddate)); fwrite ($bf,full_tag("ENROLPERIOD",3,false,$course->enrolperiod)); - + fwrite ($bf,full_tag("ENABLECOMPLETION",3,false,$course->enablecompletion)); + /// write local course overrides here? write_role_overrides_xml($bf, $context, 3); /// write role_assign code here @@ -1162,6 +1163,11 @@ $status = true; $first_record = true; + + $course=$DB->get_record('course',array('id'=>$preferences->backup_course)); + if(!$course) { + return false; + } //Now print the mods in section //Extracts mod id from sequence @@ -1221,11 +1227,36 @@ fwrite ($bf,full_tag("GROUPINGID",6,false,$course_module->groupingid)); fwrite ($bf,full_tag("GROUPMEMBERSONLY",6,false,$course_module->groupmembersonly)); fwrite ($bf,full_tag("IDNUMBER",6,false,$course_module->idnumber)); + fwrite ($bf,full_tag("COMPLETION",6,false,$course_module->completion)); + fwrite ($bf,full_tag("COMPLETIONGRADEITEMNUMBER",6,false,$course_module->completiongradeitemnumber)); + fwrite ($bf,full_tag("COMPLETIONVIEW",6,false,$course_module->completionview)); + fwrite ($bf,full_tag("COMPLETIONEXPECTED",6,false,$course_module->completionexpected)); // get all the role_capabilities overrides in this mod write_role_overrides_xml($bf, $context, 6); /// write role_assign code here - write_role_assignments_xml($bf, $preferences, $context, 6); - /// write role_assign code here + write_role_assignments_xml($bf, $preferences, $context, 6); + // write completion data if enabled and user data enabled + require_once($CFG->libdir.'/completionlib.php'); + $completion=new completion_info($course); + if($completion->is_enabled($course_module) && + backup_userdata_selected($preferences,$moduletype,$course_module->instance)) { + fwrite ($bf,start_tag("COMPLETIONDATA",6,true)); + + // Get all completion records for this module and loop + $data=$DB->get_records('course_modules_completion',array('coursemoduleid'=>$course_module->id)); + $data=$data ? $data : array(); + foreach($data as $completion) { + // Write completion record + fwrite ($bf,start_tag("COMPLETION",7,true)); + fwrite ($bf,full_tag("USERID",8,false,$completion->userid)); + fwrite ($bf,full_tag("COMPLETIONSTATE",8,false,$completion->completionstate)); + fwrite ($bf,full_tag("VIEWED",8,false,$completion->viewed)); + fwrite ($bf,full_tag("TIMEMODIFIED",8,false,$completion->timemodified)); + fwrite ($bf,end_tag("COMPLETION",7,true)); + } + + fwrite ($bf,end_tag("COMPLETIONDATA",6,true)); + } fwrite ($bf,end_tag("MOD",5,true)); } @@ -1240,7 +1271,7 @@ return $status; } - + //Print users to xml //Only users previously calculated in backup_ids will output // Index: lang/en_utf8/forum.php =================================================================== RCS file: /cvsroot/moodle/moodle/lang/en_utf8/forum.php,v retrieving revision 1.39 diff -u -r1.39 forum.php --- lang/en_utf8/forum.php 25 Jul 2008 12:30:08 -0000 1.39 +++ lang/en_utf8/forum.php 28 Jul 2008 12:05:04 -0000 @@ -40,6 +40,15 @@ $string['cannotinsertrate'] = 'Could not insert a new rating ($a[0] = $a[1])'; $string['cannottrack'] = 'Could not stop tracking that forum'; $string['cleanreadtime'] = 'Mark old posts as read hour'; +$string['completiondiscussions'] = 'User must create discussions:'; +$string['completiondiscussionshelp'] = 'requiring discussions to complete'; +$string['completiondiscussionsgroup'] = 'Require discussions'; +$string['completionposts'] = 'User must post discussions or replies:'; +$string['completionpostshelp'] = 'requiring discussions or replies to complete'; +$string['completionpostsgroup'] = 'Require posts'; +$string['completionreplies'] = 'User must post replies:'; +$string['completionreplieshelp'] = 'requiring replies to complete'; +$string['completionrepliesgroup'] = 'Require replies'; $string['configcleanreadtime'] = 'The hour of the day to clean old posts from the \'read\' table.'; $string['configdisplaymode'] = 'The default display mode for discussions if one isn\'t set.'; $string['configenablerssfeeds'] = 'This switch will enable the possibility of RSS feeds for all forums. You will still need to turn feeds on manually in the settings for each forum.'; @@ -87,11 +96,11 @@ $string['editing'] = 'Editing'; $string['emptymessage'] = 'Something was wrong with your post. Perhaps you left it blank, or the attachment was too big. Your changes have NOT been saved.'; $string['everyonecanchoose'] = 'Everyone can choose to be subscribed'; -$string['everyonecannowchoose'] = 'Everyone can now choose to be subscribed'; +$string['everyonecannowchoose'] = 'Everyone can now choose to be subscribed'; $string['everyoneisnowsubscribed'] = 'Everyone is now subscribed to this forum'; $string['everyoneissubscribed'] = 'Everyone is subscribed to this forum'; $string['existingsubscribers'] = 'Existing subscribers'; -$string['forcessubscribe'] = 'This forum forces everyone to be subscribed'; +$string['forcessubscribe'] = 'This forum forces everyone to be subscribed'; $string['forcesubscribe'] = 'Force everyone to be subscribed'; $string['forcesubscribeq'] = 'Force everyone to be subscribed?'; $string['forum'] = 'Forum'; Index: version.php =================================================================== RCS file: /cvsroot/moodle/moodle/version.php,v retrieving revision 1.737 diff -u -r1.737 version.php --- version.php 28 Jul 2008 01:37:25 -0000 1.737 +++ version.php 28 Jul 2008 12:05:00 -0000 @@ -6,7 +6,7 @@ // This is compared against the values stored in the database to determine // whether upgrades should be performed (see lib/db/*.php) - $version = 2008072600; // YYYYMMDD = date of the last version bump + $version = 2008072800; // YYYYMMDD = date of the last version bump // XX = daily increments $release = '2.0 dev (Build: 20080728)'; // Human-friendly version name Index: admin/settings/misc.php =================================================================== RCS file: /cvsroot/moodle/moodle/admin/settings/misc.php,v retrieving revision 1.19 diff -u -r1.19 misc.php --- admin/settings/misc.php 25 May 2008 11:37:45 -0000 1.19 +++ admin/settings/misc.php 28 Jul 2008 12:05:00 -0000 @@ -13,6 +13,11 @@ $temp->add($item); $temp->add(new admin_setting_configcheckbox('enablegroupings', get_string('enablegroupings', 'admin'), get_string('configenablegroupings', 'admin'), 0)); + // Completion system + require_once($CFG->libdir.'/completionlib.php'); + $temp->add(new admin_setting_configcheckbox('enablecompletion',get_string('enablecompletion','completion'),get_string('configenablecompletion','completion'),COMPLETION_ENABLED)); + $temp->add(new admin_setting_pickroles('progresstrackedroles',get_string('progresstrackedroles','completion'),get_string('configprogresstrackedroles','completion'))); + $ADMIN->add('misc', $temp); // XMLDB editor Index: lib/simpletest/testcompletionlib.php =================================================================== RCS file: lib/simpletest/testcompletionlib.php diff -N lib/simpletest/testcompletionlib.php --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ lib/simpletest/testcompletionlib.php 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,676 @@ +libdir.'/completionlib.php'); + +global $DB; +Mock::generate(get_class($DB), 'mock_database'); + +Mock::generatePartial('completion_info','completion_cutdown', + array('delete_all_state','internal_get_tracked_users','update_state', + 'internal_get_grade_state','is_enabled','get_data','internal_get_state','internal_set_data')); +Mock::generatePartial('completion_info','completion_cutdown2', + array('is_enabled','get_data','internal_get_state','internal_set_data')); +Mock::generatePartial('completion_info','completion_cutdown3', + array('internal_get_grade_state')); + +class fake_recordset implements Iterator { + var $closed; + var $values,$index; + + function fake_recordset($values) { + $this->values=$values; + $this->index=0; + } + + function current() { + return $this->values[$this->index]; + } + + function key() { + return $this->values[$this->index]; + } + + function next() { + $this->index++; + } + + function rewind() { + $this->index=0; + } + + function valid() { + return count($this->values) > $this->index; + } + + function close() { + $closed=true; + } + + function was_closed() { + return $closed; + } +} + +/** + * Expectation that checks an object for given values (normal equality test) + * plus a 'timemodified' field that is current (last second or two). + */ +class TimeModifiedExpectation extends SimpleExpectation { + private $otherfields; + + /** + * @param array $otherfields Array key=>value of required object fields + */ + function TimeModifiedExpectation($otherfields) { + $this->otherfields=$otherfields; + } + + function test($thing) { + $thingfields=(array)$thing; + foreach($this->otherfields as $key=>$value) { + if(!array_key_exists($key,$thingfields)) { + return false; + } + if($thingfields[$key]!=$value) { + return false; + } + } + + $timedifference=time()-$thing->timemodified; + return ($timedifference < 2 && $timedifference>=0); + } + + function testMessage($thing) { + return "Object does not match fields/time requirement"; + } +} + +class completionlib_test extends UnitTestCase { + var $realdb,$realcfg,$realsession,$realuser; + + function setUp() { + global $DB,$CFG,$SESSION,$USER; + $this->realdb=$DB; + $this->realcfg=$CFG; + $this->realuser=$USER; + $DB=new mock_database(); + $CFG=clone($this->realcfg); + $CFG->prefix='test_'; + $CFG->enablecompletion=COMPLETION_ENABLED; + $SESSION=new stdClass(); + $USER=(object)array('id'=>314159); + } + + function tearDown() { + global $DB,$CFG,$SESSION,$USER; + $DB=$this->realdb; + $CFG=$this->realcfg; + $SESSION=$this->realsession; + $USER=$this->realuser; + } + + function test_is_enabled() { + global $CFG; + + // Config alone + $CFG->enablecompletion=COMPLETION_DISABLED; + $this->assertEqual(COMPLETION_DISABLED,completion_info::is_enabled_for_site()); + $CFG->enablecompletion=COMPLETION_ENABLED; + $this->assertEqual(COMPLETION_ENABLED,completion_info::is_enabled_for_site()); + + // Course + $course=new stdClass; + $c=new completion_info($course); + $course->enablecompletion=COMPLETION_DISABLED; + $this->assertEqual(COMPLETION_DISABLED,$c->is_enabled()); + $course->enablecompletion=COMPLETION_ENABLED; + $this->assertEqual(COMPLETION_ENABLED,$c->is_enabled()); + $CFG->enablecompletion=COMPLETION_DISABLED; + $this->assertEqual(COMPLETION_DISABLED,$c->is_enabled()); + + // Course and CM + $cm=new stdClass; + $cm->completion=COMPLETION_TRACKING_MANUAL; + $this->assertEqual(COMPLETION_DISABLED,$c->is_enabled($cm)); + $CFG->enablecompletion=COMPLETION_ENABLED; + $course->enablecompletion=COMPLETION_DISABLED; + $this->assertEqual(COMPLETION_DISABLED,$c->is_enabled($cm)); + $course->enablecompletion=COMPLETION_ENABLED; + $this->assertEqual(COMPLETION_TRACKING_MANUAL,$c->is_enabled($cm)); + $cm->completion=COMPLETION_TRACKING_NONE; + $this->assertEqual(COMPLETION_TRACKING_NONE,$c->is_enabled($cm)); + $cm->completion=COMPLETION_TRACKING_AUTOMATIC; + $this->assertEqual(COMPLETION_TRACKING_AUTOMATIC,$c->is_enabled($cm)); + } + + function test_update_state() { + $c=new completion_cutdown2(); + $c->completion_info((object)array('id'=>42)); + $cm=(object)array('id'=>13,'course'=>42); + + // Not enabled, should do nothing + $c->expectAt(0,'is_enabled',array($cm)); + $c->setReturnValueAt(0,'is_enabled',false); + $c->update_state($cm); + + // Enabled, but current state is same as possible result, do nothing + $current=(object)array('completionstate'=>COMPLETION_COMPLETE); + $c->expectAt(1,'is_enabled',array($cm)); + $c->setReturnValueAt(1,'is_enabled',true); + + $c->expectAt(0,'get_data',array($cm,false,0)); + $c->setReturnValueAt(0,'get_data',$current); + $c->update_state($cm,COMPLETION_COMPLETE); + + // Enabled, but current state is a specific one and new state is just + // omplete, so do nothing + $current->completionstate=COMPLETION_COMPLETE_PASS; + $c->expectAt(2,'is_enabled',array($cm)); + $c->setReturnValueAt(2,'is_enabled',true); + $c->expectAt(1,'get_data',array($cm,false,0)); + $c->setReturnValueAt(1,'get_data',$current); + $c->update_state($cm,COMPLETION_COMPLETE); + + // Manual, change state (no change) + $cm->completion=COMPLETION_TRACKING_MANUAL; + $current->completionstate=COMPLETION_COMPLETE; + $c->expectAt(3,'is_enabled',array($cm)); + $c->setReturnValueAt(3,'is_enabled',true); + $c->expectAt(2,'get_data',array($cm,false,0)); + $c->setReturnValueAt(2,'get_data',$current); + $c->update_state($cm,COMPLETION_COMPLETE); + + // Manual, change state (change) + $c->expectAt(4,'is_enabled',array($cm)); + $c->setReturnValueAt(4,'is_enabled',true); + $c->expectAt(3,'get_data',array($cm,false,0)); + $c->setReturnValueAt(3,'get_data',$current); + $c->expectAt(0,'internal_set_data',array($cm, + new TimeModifiedExpectation(array('completionstate'=>COMPLETION_INCOMPLETE)))); + $c->update_state($cm,COMPLETION_INCOMPLETE); + + // Auto, change state + $cm->completion=COMPLETION_TRACKING_AUTOMATIC; + $c->expectAt(5,'is_enabled',array($cm)); + $c->setReturnValueAt(5,'is_enabled',true); + $c->expectAt(4,'get_data',array($cm,false,0)); + $c->setReturnValueAt(4,'get_data',$current); + $c->expectAt(0,'internal_get_state',array($cm,0,$current)); + $c->setReturnValueAt(0,'internal_get_state',COMPLETION_COMPLETE_PASS); + $c->expectAt(1,'internal_set_data',array($cm, + new TimeModifiedExpectation(array('completionstate'=>COMPLETION_COMPLETE_PASS)))); + $c->update_state($cm,COMPLETION_COMPLETE_PASS); + + $c->tally(); + } + + function test_internal_get_state() { + global $DB; + + $c=new completion_cutdown3(); + $c->completion_info((object)array('id'=>42)); + $cm=(object)array('id'=>13,'course'=>42,'completiongradeitemnumber'=>null); + + // If view is required, but they haven't viewed it yet + $cm->completionview=COMPLETION_VIEW_REQUIRED; + $current=(object)array('viewed'=>COMPLETION_NOT_VIEWED); + $this->assertEqual(COMPLETION_INCOMPLETE,$c->internal_get_state($cm,123,$current)); + + // OK set view not required + $cm->completionview=COMPLETION_VIEW_NOT_REQUIRED; + + // Test not getting module name + $cm->modname='label'; + $this->assertEqual(COMPLETION_COMPLETE,$c->internal_get_state($cm,123,$current)); + + // Test getting module name + $cm->module=13; + unset($cm->modname); + $DB->expectOnce('get_field',array('modules','name',array('id'=>13))); + $DB->setReturnValue('get_field','label'); + $this->assertEqual(COMPLETION_COMPLETE,$c->internal_get_state($cm,123,$current)); + + // Note: This function is not fully tested (including kind of the main + // part) because: + // * the grade_item/grade_grade calls are static and can't be mocked + // * the plugin_supports call is static and can't be mocked + + $DB->tally(); + $c->tally(); + } + + function test_set_module_viewed() { + $c=new completion_cutdown(); + $c->completion_info((object)array('id'=>42)); + $cm=(object)array('id'=>13,'course'=>42); + + // Not tracking completion, should do nothing + $cm->completionview=COMPLETION_VIEW_NOT_REQUIRED; + $c->set_module_viewed($cm); + + // Tracking completion but completion is disabled, should do nothing + $cm->completionview=COMPLETION_VIEW_REQUIRED; + $c->expectAt(0,'is_enabled',array($cm)); + $c->setReturnValueAt(0,'is_enabled',false); + $c->set_module_viewed($cm); + + // Now it's enabled, we expect it to get data. If data already has + // viewed, still do nothing + $c->expectAt(1,'is_enabled',array($cm)); + $c->setReturnValueAt(1,'is_enabled',true); + $c->expectAt(0,'get_data',array($cm,0)); + $hasviewed=(object)array('viewed'=>COMPLETION_VIEWED); + $c->setReturnValueAt(0,'get_data',$hasviewed); + $c->set_module_viewed($cm); + + // OK finally one that hasn't been viewed, now it should set it viewed + // and update state + $c->expectAt(2,'is_enabled',array($cm)); + $c->setReturnValueAt(2,'is_enabled',true); + $notviewed=(object)array('viewed'=>COMPLETION_NOT_VIEWED); + $c->expectAt(1,'get_data',array($cm,1337)); + $c->setReturnValueAt(1,'get_data',$notviewed); + $c->expectOnce('internal_set_data',array($cm,$hasviewed)); + $c->expectOnce('update_state',array($cm,COMPLETION_COMPLETE,1337)); + $c->set_module_viewed($cm,1337); + + $c->tally(); + } + + function test_count_user_data() { + global $DB; + $cm=(object)array('id'=>42); + $DB->setReturnValue('get_field_sql',666); + $DB->expectOnce('get_field_sql',array(new IgnoreWhitespaceExpectation("SELECT + COUNT(1) +FROM + test_course_modules_completion +WHERE + coursemoduleid=? AND completionstate<>0"),array(42))); + $c=new completion_info(null); + $this->assertEqual(666,$c->count_user_data($cm)); + + $DB->tally(); + } + + function test_delete_all_state() { + global $DB,$SESSION; + $course=(object)array('id'=>13); + $cm=(object)array('id'=>42,'course'=>13); + $c=new completion_info($course); + // Check it works ok without data in session + $DB->expectAt(0,'delete_records', + array('course_modules_completion',array('coursemoduleid'=>42))); + $c->delete_all_state($cm); + + // Build up a session to check it deletes the right bits from it + // (and not other bits) + $SESSION->completioncache=array(); + $SESSION->completioncache[13]=array(); + $SESSION->completioncache[13][42]='foo'; + $SESSION->completioncache[13][43]='foo'; + $SESSION->completioncache[14]=array(); + $SESSION->completioncache[14][42]='foo'; + $DB->expectAt(1,'delete_records', + array('course_modules_completion',array('coursemoduleid'=>42))); + $c->delete_all_state($cm); + $this->assertEqual(array(13=>array(43=>'foo'),14=>array(42=>'foo')), + $SESSION->completioncache); + + $DB->tally(); + } + + function test_reset_all_state() { + global $DB; + $c=new completion_cutdown(); + $c->completion_info((object)array('id'=>42)); + + $cm=(object)array('id'=>13,'course'=>42); + + $DB->setReturnValue('get_recordset',new fake_recordset(array( + (object)array('id'=>1,'userid'=>100), + (object)array('id'=>2,'userid'=>101), + ))); + $DB->expectOnce('get_recordset',array('course_modules_completion', + array('coursemoduleid'=>13),'','userid')); + $c->expectOnce('delete_all_state',array($cm)); + $c->expectOnce('internal_get_tracked_users',array(false)); + $c->setReturnValue('internal_get_tracked_users',array( + (object)array('id'=>100,'firstname'=>'Woot','lastname'=>'Plugh'), + (object)array('id'=>201,'firstname'=>'Vroom','lastname'=>'Xyzzy'), + )); + + $c->expectAt(0,'update_state',array($cm,COMPLETION_UNKNOWN,100)); + $c->expectAt(1,'update_state',array($cm,COMPLETION_UNKNOWN,101)); + $c->expectAt(2,'update_state',array($cm,COMPLETION_UNKNOWN,201)); + + $c->reset_all_state($cm); + + $DB->tally(); + $c->tally(); + } + + function test_get_data() { + global $DB,$SESSION; + + $c=new completion_info((object)array('id'=>42)); + $cm=(object)array('id'=>13,'course'=>42); + + // 1. Not current user, record exists + $sillyrecord=(object)array('frog'=>'kermit'); + $DB->expectAt(0,'get_record',array('course_modules_completion', + array('coursemoduleid'=>13,'userid'=>123))); + $DB->setReturnValueAt(0,'get_record',$sillyrecord); + $result=$c->get_data($cm,false,123); + $this->assertEqual($sillyrecord,$result); + $this->assertTrue(empty($SESSION->completioncache)); + + // 2. Not current user, default record, wholecourse (ignored) + $DB->expectAt(1,'get_record',array('course_modules_completion', + array('coursemoduleid'=>13,'userid'=>123))); + $DB->setReturnValueAt(1,'get_record',false); + $result=$c->get_data($cm,true,123); + $this->assertEqual((object)array( + 'id'=>'0','coursemoduleid'=>13,'userid'=>123,'completionstate'=>0, + 'viewed'=>0,'timemodified'=>0),$result); + $this->assertTrue(empty($SESSION->completioncache)); + + // 3. Current user, single record, not from cache + $DB->expectAt(2,'get_record',array('course_modules_completion', + array('coursemoduleid'=>13,'userid'=>314159))); + $DB->setReturnValueAt(2,'get_record',$sillyrecord); + $result=$c->get_data($cm); + $this->assertEqual($sillyrecord,$result); + $this->assertEqual($sillyrecord,$SESSION->completioncache[42][13]); + // When checking time(), allow for second overlaps + $this->assertTrue(time()-$SESSION->completioncache[42]['updated']<2); + + // 4. Current user, 'whole course', but from cache + $result=$c->get_data($cm,true); + $this->assertEqual($sillyrecord,$result); + + // 5. Current user, single record, cache expired + $SESSION->completioncache[42]['updated']=37; // Quite a long time ago + $now=time(); + $SESSION->completioncache[17]['updated']=$now; + $SESSION->completioncache[39]['updated']=72; // Also a long time ago + $DB->expectAt(3,'get_record',array('course_modules_completion', + array('coursemoduleid'=>13,'userid'=>314159))); + $DB->setReturnValueAt(3,'get_record',$sillyrecord); + $result=$c->get_data($cm,false); + $this->assertEqual($sillyrecord,$result); + // Check that updated value is right, then fudge it to make next compare + // work + $this->assertTrue(time()-$SESSION->completioncache[42]['updated']<2); + $SESSION->completioncache[42]['updated']=$now; + // Check things got expired from cache + $this->assertEqual(array(42=>array(13=>$sillyrecord,'updated'=>$now), + 17=>array('updated'=>$now)),$SESSION->completioncache); + + // 6. Current user, 'whole course' and record not in cache + unset($SESSION->completioncache); + + // Scenario: Completion data exists for one CMid + $basicrecord=(object)array('coursemoduleid'=>13); + $DB->setReturnValueAt(0,'get_records_sql',array( + 1=>$basicrecord + )); + $DB->expectAt(0,'get_records_sql',array(new IgnoreWhitespaceExpectation(" +SELECT + cmc.* +FROM + test_course_modules cm + INNER JOIN test_course_modules_completion cmc ON cmc.coursemoduleid=cm.id +WHERE + cm.course=? AND cmc.userid=?"),array(42,314159))); + + // There are two CMids in total, the one we had data for and another one + $modinfo->cms=array((object)array('id'=>13),(object)array('id'=>14)); + $result=$c->get_data($cm,true,0,$modinfo); + + // Check result + $this->assertEqual($basicrecord,$result); + + // Check the cache contents + $this->assertTrue(time()-$SESSION->completioncache[42]['updated']<2); + $SESSION->completioncache[42]['updated']=$now; + $this->assertEqual(array(42=>array(13=>$basicrecord,14=>(object)array( + 'id'=>'0','coursemoduleid'=>14,'userid'=>314159,'completionstate'=>0, + 'viewed'=>0,'timemodified'=>0),'updated'=>$now)),$SESSION->completioncache); + + $DB->tally(); + } + + function test_internal_set_data() { + global $DB,$SESSION; + + $cm=(object)array('course'=>42,'id'=>13); + $c=new completion_info((object)array('id'=>42)); + + // 1) Test with new data + $data=(object)array('id'=>0,'userid'=>314159); + $DB->setReturnValueAt(0,'insert_record',4); + $DB->expectAt(0,'insert_record',array('course_modules_completion',$data)); + $c->internal_set_data($cm,$data); + $this->assertEqual(4,$data->id); + $this->assertEqual(array(42=>array(13=>$data)),$SESSION->completioncache); + + // 2) Test with existing data and for different user (not cached) + unset($SESSION->completioncache); + $d2=(object)array('id'=>7,'userid'=>17); + $DB->expectAt(0,'update_record',array('course_modules_completion',$d2)); + $c->internal_set_data($cm,$d2); + $this->assertFalse(isset($SESSION->completioncache)); + + $DB->tally(); + } + + function test_get_activities() { + global $DB; + + $c=new completion_info((object)array('id'=>42)); + + // Try with no activities + $DB->expectAt(0,'get_records_select',array('course_modules', + 'course=42 AND completion<>'.COMPLETION_TRACKING_NONE)); + $DB->setReturnValueAt(0,'get_records_select',array()); + $result=$c->get_activities(); + $this->assertEqual(array(),$result); + + // Try with an activity (need to fake up modinfo for it as well) + $DB->expectAt(1,'get_records_select',array('course_modules', + 'course=42 AND completion<>'.COMPLETION_TRACKING_NONE)); + $DB->setReturnValueAt(1,'get_records_select',array( + 13=>(object)array('id'=>13) + )); + $modinfo=new stdClass; + $modinfo->sections=array(array(1,2,3),array(12,13,14)); + $modinfo->cms[13]=(object)array('modname'=>'frog','name'=>'kermit'); + $result=$c->get_activities($modinfo); + $this->assertEqual(array(13=>(object)array('id'=>13,'modname'=>'frog','name'=>'kermit')),$result); + + $DB->tally(); + } + + // internal_get_tracked_users() cannot easily be tested because it uses + // get_role_users, so skipping that + + function test_get_progress_all() { + global $DB; + + $c=new completion_cutdown(); + $c->completion_info((object)array('id'=>42)); + + // 1) Basic usage + $c->expectAt(0,'internal_get_tracked_users',array(false,0)); + $c->setReturnValueAt(0,'internal_get_tracked_users',array( + (object)array('id'=>100,'firstname'=>'Woot','lastname'=>'Plugh'), + (object)array('id'=>201,'firstname'=>'Vroom','lastname'=>'Xyzzy'), + )); + $DB->expectAt(0,'get_in_or_equal',array(array(100,201))); + $DB->setReturnValueAt(0,'get_in_or_equal',array(' IN (100,201)',array())); + $DB->expectAt(0,'get_recordset_sql',array(new IgnoreWhitespaceExpectation(" +SELECT + cmc.* +FROM + test_course_modules cm + INNER JOIN test_course_modules_completion cmc ON cm.id=cmc.coursemoduleid +WHERE + cm.course=? AND cmc.userid IN (100,201)"),array(42))); + $progress1=(object)array('userid'=>100,'coursemoduleid'=>13); + $progress2=(object)array('userid'=>201,'coursemoduleid'=>14); + $DB->setReturnValueAt(0,'get_recordset_sql',new fake_recordset(array( + $progress1,$progress2 + ))); + + $this->assertEqual(array( + 100 => (object)array('id'=>100,'firstname'=>'Woot','lastname'=>'Plugh', + 'progress'=>array(13=>$progress1)), + 201 => (object)array('id'=>201,'firstname'=>'Vroom','lastname'=>'Xyzzy', + 'progress'=>array(14=>$progress2)), + ),$c->get_progress_all(false)); + + // 2) With more than 1,000 results + $c->expectAt(1,'internal_get_tracked_users',array(true,3)); + + $tracked=array(); + $ids=array(); + $progress=array(); + for($i=100;$i<2000;$i++) { + $tracked[]=(object)array('id'=>$i,'firstname'=>'frog','lastname'=>$i); + $ids[]=$i; + $progress[]=(object)array('userid'=>$i,'coursemoduleid'=>13); + $progress[]=(object)array('userid'=>$i,'coursemoduleid'=>14); + } + $c->setReturnValueAt(1,'internal_get_tracked_users',$tracked); + + $DB->expectAt(1,'get_in_or_equal',array(array_slice($ids,0,1000))); + $DB->setReturnValueAt(1,'get_in_or_equal',array(' IN whatever',array())); + $DB->expectAt(1,'get_recordset_sql',array(new IgnoreWhitespaceExpectation(" +SELECT + cmc.* +FROM + test_course_modules cm + INNER JOIN test_course_modules_completion cmc ON cm.id=cmc.coursemoduleid +WHERE + cm.course=? AND cmc.userid IN whatever"),array(42))); + $DB->setReturnValueAt(1,'get_recordset_sql',new fake_recordset(array_slice($progress,0,1000))); + $DB->expectAt(2,'get_in_or_equal',array(array_slice($ids,1000))); + $DB->setReturnValueAt(2,'get_in_or_equal',array(' IN whatever2',array())); + $DB->expectAt(2,'get_recordset_sql',array(new IgnoreWhitespaceExpectation(" +SELECT + cmc.* +FROM + test_course_modules cm + INNER JOIN test_course_modules_completion cmc ON cm.id=cmc.coursemoduleid +WHERE + cm.course=? AND cmc.userid IN whatever2"),array(42))); + $DB->setReturnValueAt(2,'get_recordset_sql',new fake_recordset(array_slice($progress,1000))); + + $result=$c->get_progress_all(true,3); + + $resultok=true; + $resultok = $resultok && ($ids==array_keys($result)); + foreach($result as $userid => $data) { + $resultok = $resultok && $data->firstname=='frog'; + $resultok = $resultok && $data->lastname==$userid; + $resultok = $resultok && $data->id==$userid; + $cms=$data->progress; + $resultok= $resultok && (array(13,14)==array_keys($cms)); + $resultok= $resultok && ((object)array('userid'=>$userid,'coursemoduleid'=>13)==$cms[13]); + $resultok= $resultok && ((object)array('userid'=>$userid,'coursemoduleid'=>14)==$cms[14]); + } + $this->assertTrue($resultok); + + $DB->tally(); + $c->tally(); + } + + function test_inform_grade_changed() { + $c=new completion_cutdown(); + $c->completion_info((object)array('id'=>42)); + + $cm=(object)array('course'=>42,'id'=>13,'completiongradeitemnumber'=>null); + $item=(object)array('itemnumber'=>3); + $grade=(object)array('userid'=>31337); + + // Not enabled (should do nothing) + $c->setReturnValueAt(0,'is_enabled',false); + $c->expectAt(0,'is_enabled',array($cm)); + $c->inform_grade_changed($cm,$item,$grade,false); + + // Enabled but still no grade completion required, should still do nothing + $c->setReturnValueAt(1,'is_enabled',true); + $c->expectAt(1,'is_enabled',array($cm)); + $c->inform_grade_changed($cm,$item,$grade,false); + + // Enabled and completion required but item number is wrong, does nothing + $cm->completiongradeitemnumber=7; + $c->setReturnValueAt(2,'is_enabled',true); + $c->expectAt(2,'is_enabled',array($cm)); + $c->inform_grade_changed($cm,$item,$grade,false); + + // Enabled and completion required and item number right. It is supposed + // to call update_state with the new potential state being obtained from + // internal_get_grade_state. + $cm->completiongradeitemnumber=3; + $c->setReturnValueAt(3,'is_enabled',true); + $c->expectAt(3,'is_enabled',array($cm)); + $c->expectAt(0,'internal_get_grade_state',array($item,$grade)); + $c->setReturnValueAt(0,'internal_get_grade_state',COMPLETION_COMPLETE_PASS); + $c->expectAt(0,'update_state',array($cm,COMPLETION_COMPLETE_PASS,31337)); + $c->inform_grade_changed($cm,$item,$grade,false); + + // Same as above but marked deleted. It is supposed to call update_state + // with new potential state being COMPLETION_INCOMPLETE + $c->setReturnValueAt(4,'is_enabled',false); + $c->expectAt(4,'is_enabled',array($cm)); + $c->expectAt(1,'update_state',array($cm,COMPLETION_INCOMPLETE,31337)); + $c->inform_grade_changed($cm,$item,$grade,false); + + $c->tally(); + } + + function test_internal_get_grade_state() { + $item=new stdClass; + $grade=new stdClass; + + $item->gradepass=4; + $item->hidden=0; + $grade->rawgrade=4.0; + $grade->finalgrade=null; + + // Grade has pass mark and is not hidden, user passes + $this->assertEqual( + COMPLETION_COMPLETE_PASS, + completion_info::internal_get_grade_state($item,$grade)); + + // Same but user fails + $grade->rawgrade=3.9; + $this->assertEqual( + COMPLETION_COMPLETE_FAIL, + completion_info::internal_get_grade_state($item,$grade)); + + // User fails on raw grade but passes on final + $grade->finalgrade=4.0; + $this->assertEqual( + COMPLETION_COMPLETE_PASS, + completion_info::internal_get_grade_state($item,$grade)); + + // Item is hidden + $item->hidden=1; + $this->assertEqual( + COMPLETION_COMPLETE, + completion_info::internal_get_grade_state($item,$grade)); + + // Item isn't hidden but has no pass mark + $item->hidden=0; + $item->gradepass=0; + $this->assertEqual( + COMPLETION_COMPLETE, + completion_info::internal_get_grade_state($item,$grade)); + } +} +?> Index: lib/simpletest/completion.manualtest.txt =================================================================== RCS file: lib/simpletest/completion.manualtest.txt diff -N lib/simpletest/completion.manualtest.txt --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ lib/simpletest/completion.manualtest.txt 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,167 @@ +Completion system manual test +============================= + +This text file describes a manual process which can be used to check that the +completion system is working correctly. This does not exercise every possible +element of the completion system but it covers most of the basic parts. + +Site setup +---------- + +1) Make a fresh install of the Moodle version you are testing. + +CHECK A: There is no error when installing the completion database tables. + +2) Create users 'admin' (as part of install) and 'u1' +3) Set the server debug to 'Developer' so that you spot any warnings etc. +4) Create course 'CF101' (leave default except pick separate groups) and + assign 'u1' as student + + +Course setup +------------ + +1) Create a web page resource 'w' +2) Create a forum 'f1' +3) Create a forum 'f2' +4) Create a quiz 'q1' with one question (e.g. true/false question) +5) Create a quiz 'q2' with one question (can use same question) +6) In gradebook/reports/categories and items, edit q2's grade (show advanced) + to assign a 'grade to pass' of 5.0. + [Note: Due to a bug in gradebook at time of writing, you may have to go into + admin screens and make sure that 'Student' is included on the list of graded + roles, or there will be an error at this step.] +7) Create 2 groups on the course. Assign u1 to one group. + +Completion settings +------------------- + +1) Visit the course setting screen. + +CHECK B: The completion controls appear. Completion is enabled. + +2) Turn off the setting (disable completion) and save. +3) Visit the admin page. Find the enablecompletion setting. + +CHECK C: The enablecompletion setting appears. Completion is enabled. + +4) Turn off this setting and save. + +4b) Note: At present I have not found a satisfactory way to set a default + for the config option, so if necessary, please manually tick the 'Student' + checkbox while on this screen. + +5) Visit the course setting screen again. + +CHECK D: The completion controls do not appear. + +6) Visit the setting screen for 'w' + +CHECK E: Completion controls do not appear + +7) Go to admin screen and turn completion on again, then return to the 'w' settings + +CHECK F: Completion controls still do not appear + +8) Go to course settings and turn completion on, then return to 'w' settings + +CHECK G: Completion controls appear. Completion is set to manual. + +9) Go to 'f1' settings. Set completion to automatic and to 2 discussions/replies. +10) Go to 'f2' settings. Set completion to automatic and to 'view' +11) Go to 'q1' and 'q2' settings; set both to automatic and 'grade'. Set them + to grade based on the most recent attempt rather than 'highest'. + +Completion actions +------------------ + +Note: Icons are subject to change, so references to a 'tick' etc might not be +correct. + +1) Log in as u1 and go to CF101. + +CHECK H: A completion tick (unticked) is visible next to 'w'. + +2) Click the completion mark a few times. + +CHECK I: Completion toggles successfully. + + 2b) Go to u1's profile settings and turn on/off AJAX then repeat toggling the + mark. Leave it ticked. + CHECK I2: Completion still toggles successfully. + +3) Visit 'f1' and post 1 message. Return to course home. + +CHECK J: There is no tickmark next to f1. (If examined carefully, the 'not + complete' icon should be present.) + +4) Visit 'f1' and post a reply to the message. Return to course home. + +CHECK K: There is now a tick next to f1. + +5) Visit 'f2' and return to the home page. + +CHECK L: There is now a tick next to f2. + +6) Visit 'q1' and attempt the quiz, getting it wrong and submitting answer. + Return to the home page. + +CHECK M: There is a black 'completed' tick next to q1. + +7) Visit 'q1' again and this time get it right. Return to home page. + +CHECK N: There is still a black 'completed' tick next to q1. + +8) Visit 'q2' and get it right. Return to home. + +CHECK O: There is a green 'completed-passed' tick next to q2. + +9) Visit 'q2' and get it wrong. Return to home. + +CHECK P: There is a red 'completed-failed' X next to q2. + +Completion progress +------------------- + +1) Log in as admin again. + +2) From the course admin block, click on the reports link. + +CHECK Q: A 'completion progress' link appears. + +3) Click on the completion progress link. + +CHECK R: + A groups dropdown should show the two groups (and 'all'). + The progress table should include all activities for which completion + was set, across the top. + The progress table should show u1 down the side. + Tick and X icons should match those shown when logged in as u1. + +4) Choose a group that does not include u1 + +CHECK S: + An informational ('no users') message should display instead of the progress + table. + +5) Choose the group that does include u1 + +CHECK T: + The progress table should show u1 again. + +Backup/restore +-------------- + +1) Backup the course. Choose 'course users', user data for everything except + q1 ('no user data'), and default options. + +2) Restore to a new course, accepting all defaults. + +3) Log in as u1 again and visit the new course. + +CHECK U: + Completion should appear as it did in the previous version of the course + ('w','f1','f2' complete, 'q2' complete-fail) except that q1 should show as + incomplete. + + Index: course/completion.js =================================================================== RCS file: course/completion.js diff -N course/completion.js --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ course/completion.js 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,73 @@ +var completion_strsaved; + +function completion_init() { + var toggles=YAHOO.util.Dom.getElementsByClassName('togglecompletion', 'form'); + for(var i=0;i+When this option is turned on, students have to view the activity in order +to complete it. +
+ ++ * For manual completion, this function is called when completion is toggled + * with $possibleresult set to the target state. + *
+ * For automatic completion, this function should be called every time a module + * does something which might influence a user's completion state. For example, + * if a forum provides options for marking itself 'completed' once a user makes + * N posts, this function should be called every time a user makes a new post. + * [After the post has been saved to the database]. When calling, you do not + * need to pass in the new completion state. Instead this function carries out + * completion calculation by checking grades and viewed state itself, and + * calling the involved module via modulename_get_completion_state() to check + * module-specific conditions. + * + * @param object $cm Course-module + * @param int $possibleresult Expected completion result. If the event that + * has just occurred (e.g. add post) can only result in making the activity + * complete when it wasn't before, use COMPLETION_COMPLETE. If the event that + * has just occurred (e.g. delete post) can only result in making the activity + * not complete when it was previously complete, use COMPLETION_INCOMPLETE. + * Otherwise use COMPLETION_UNKNOWN. Setting this value to something other than + * COMPLETION_UNKNOWN significantly improves performance because it will abandon + * processing early if the user's completion state already matches the expected + * result. For manual events, COMPLETION_COMPLETE or COMPLETION_INCOMPLETE + * must be used; these directly set the specified state. + * @param int $userid User ID to be updated. Default 0 = current user + */ + public function update_state($cm,$possibleresult=COMPLETION_UNKNOWN,$userid=0) { + global $USER,$SESSION; + // Do nothing if completion is not enabled for that activity + if(!$this->is_enabled($cm)) { + return; + } + + // Get current value of completion state and do nothing if it's same as + // the possible result of this change. If the change is to COMPLETE and the + // current value is one of the COMPLETE_xx subtypes, ignore that as well + $current=$this->get_data($cm,false,$userid); + if($possibleresult==$current->completionstate || + ($possibleresult==COMPLETION_COMPLETE && + ($current->completionstate==COMPLETION_COMPLETE_PASS || + $current->completionstate==COMPLETION_COMPLETE_FAIL))) { + return; + } + + if($cm->completion==COMPLETION_TRACKING_MANUAL) { + // For manual tracking we set the result directly + switch($possibleresult) { + case COMPLETION_COMPLETE: + case COMPLETION_INCOMPLETE: + $newstate=$possibleresult; + break; + default: + $this->internal_systemerror("Unexpected manual completion state for {$cm->id}: $possibleresult"); + } + } else { + // Automatic tracking; get new state + $newstate=$this->internal_get_state($cm,$userid,$current); + } + + // If changed, update + if($newstate!=$current->completionstate) { + $current->completionstate=$newstate; + $current->timemodified=time(); + $this->internal_set_data($cm,$current); + } + } + + /** + * Calculates the completion state for an activity and user. + *
+ * (Internal function. Not private, so we can unit-test it.) + * + * @param object $cm Activity + * @param int $userid ID of user + * @param object $current Previous completion information from database + * @return unknown + */ + function internal_get_state($cm,$userid,$current) { + // Get user ID + global $USER,$DB; + if(!$userid) { + $userid=$USER->id; + } + + // Check viewed + if($cm->completionview==COMPLETION_VIEW_REQUIRED && + $current->viewed==COMPLETION_NOT_VIEWED) { + return COMPLETION_INCOMPLETE; + } + + // Modname hopefully is provided in $cm but just in case it isn't, let's grab it + if(!isset($cm->modname)) { + $cm->modname=$DB->get_field('modules','name',array('id'=>$cm->module)); + } + + $newstate=COMPLETION_COMPLETE; + + // Check grade + if(!is_null($cm->completiongradeitemnumber)) { + $item=grade_item::fetch(array('courseid'=>$cm->course,'itemtype'=>'mod', + 'itemmodule'=>$cm->modname,'iteminstance'=>$cm->instance, + 'itemnumber'=>$cm->completiongradeitemnumber)); + if($item) { + // Fetch 'grades' (will be one or none) + $grades=grade_grade::fetch_users_grades($item,array($userid),false); + if(empty($grades)) { + // No grade for user + return COMPLETION_INCOMPLETE; + } + if(count($grades)>1) { + $this->internal_systemerror("Unexpected result: multiple grades for + item '{$item->id}', user '{$userid}'"); + } + $newstate=$this->internal_get_grade_state($item,reset($grades)); + if($newstate==COMPLETION_INCOMPLETE) { + return COMPLETION_INCOMPLETE; + } + } else { + $this->internal_systemerror("Cannot find grade item for '{$cm->modname}' + cm '{$cm->id}' matching number '{$cm->completiongradeitemnumber}'"); + } + } + + if(plugin_supports('mod',$cm->modname,FEATURE_COMPLETION_HAS_RULES)) { + $function=$cm->modname.'_get_completion_state'; + if(!function_exists($function)) { + $this->internal_systemerror("Module {$cm->modname} claims to support + FEATURE_COMPLETION_HAS_RULES but does not have required + {$cm->modname}_get_completion_state function"); + } + if(!$function($this->course,$cm,$userid,COMPLETION_AND)) { + return COMPLETION_INCOMPLETE; + } + } + + return $newstate; + + } + + + /** + * Marks a module as viewed. + *
+ * Should be called whenever a module is 'viewed' (it is up to the module how to + * determine that). Has no effect if viewing is not set as a completion condition. + * + * @param object $cm Activity + * @param int $userid User ID or 0 (default) for current user + */ + public function set_module_viewed($cm,$userid=0) { + // Don't do anything if view condition is not turned on + if($cm->completionview==COMPLETION_VIEW_NOT_REQUIRED || !$this->is_enabled($cm)) { + return; + } + // Get current completion state + $data=$this->get_data($cm,$userid); + // If we already viewed it, don't do anything + if($data->viewed==COMPLETION_VIEWED) { + return; + } + // OK, change state, save it, and update completion + $data->viewed=COMPLETION_VIEWED; + $this->internal_set_data($cm,$data); + $this->update_state($cm,COMPLETION_COMPLETE,$userid); + } + + /** + * Determines how much completion data exists for an activity. This is used when + * deciding whether completion information should be 'locked' in the module + * editing form. + * + * @param object $cm Activity + * @return int The number of users who have completion data stored for this + * activity, 0 if none + */ + public function count_user_data($cm) { + global $CFG,$DB; + + return $DB->get_field_sql(" + SELECT + COUNT(1) + FROM + {$CFG->prefix}course_modules_completion + WHERE + coursemoduleid=? AND completionstate<>0",array($cm->id)); + } + + /** + * Deletes completion state related to an activity for all users. + *
+ * Intended for use only when the activity itself is deleted. + * + * @param object $cm Activity + */ + public function delete_all_state($cm) { + global $SESSION,$DB; + + // Delete from database + $DB->delete_records('course_modules_completion',array('coursemoduleid'=>$cm->id)); + + // Erase cache data for current user if applicable + if(isset($SESSION->completioncache) && + array_key_exists($cm->course,$SESSION->completioncache) && + array_key_exists($cm->id,$SESSION->completioncache[$cm->course])) { + unset($SESSION->completioncache[$cm->course][$cm->id]); + } + } + + /** + * Recalculates completion state related to an activity for all users. + *
+ * Intended for use if completion conditions change. (This should be avoided + * as it may cause some things to become incomplete when they were previously + * complete, with the effect - for example - of hiding a later activity that + * was previously available.) + * + * @param object $cm Activity + */ + public function reset_all_state($cm) { + global $DB; + // Get current list of users with completion state + $rs=$DB->get_recordset('course_modules_completion',array('coursemoduleid'=>$cm->id),'','userid'); + $keepusers=array(); + foreach($rs as $rec) { + $keepusers[]=$rec->userid; + } + $rs->close(); + + // Delete all existing state [also clears session cache for current user] + $this->delete_all_state($cm); + + // Merge this with list of planned users (according to roles) + $trackedusers=$this->internal_get_tracked_users(false); + foreach($trackedusers as $trackeduser) { + $keepusers[]=$trackeduser->id; + } + $keepusers=array_unique($keepusers); + + // Recalculate state for each kept user + foreach($keepusers as $keepuser) { + $this->update_state($cm,COMPLETION_UNKNOWN,$keepuser); + } + } + + /** + * Obtains completion data for a particular activity and user (from the + * session cache if available, or by SQL query) + * + * @param object $cm Activity + * @param bool $wholecourse If true (default false) then, when necessary to + * fill the cache, retrieves information from the entire course not just for + * this one activity + * @param int $userid User ID or 0 (default) for current user + * @param array $modinfo For unit testing only, supply the value + * here. Otherwise the method calls get_fast_modinfo + * @return object Completion data (record from course_modules_completion) + * @throws Exception In some cases where the requested course-module is not + * found on the specified course + */ + public function get_data($cm,$wholecourse=false,$userid=0,$modinfo=null) { + // Get user ID + global $USER,$CFG,$SESSION,$DB; + if(!$userid) { + $userid=$USER->id; + } + + // Is this the current user? + $currentuser=$userid==$USER->id; + + if($currentuser) { + // Make sure cache is present + if(!isset($SESSION->completioncache)) { + $SESSION->completioncache=array(); + } + // Expire any old data from cache + foreach($SESSION->completioncache as $courseid=>$activities) { + if(empty($activities['updated']) || $activities['updated'] < time()-COMPLETION_CACHE_EXPIRY) { + unset($SESSION->completioncache[$courseid]); + } + } + // See if requested data is present, if so use cache to get it + if(isset($SESSION->completioncache) && + array_key_exists($this->course->id,$SESSION->completioncache) && + array_key_exists($cm->id,$SESSION->completioncache[$this->course->id])) { + return $SESSION->completioncache[$this->course->id][$cm->id]; + } + } + + // Not there, get via SQL + if($currentuser && $wholecourse) { + // Get whole course data for cache + $alldatabycmc=$DB->get_records_sql(" + SELECT + cmc.* + FROM + {$CFG->prefix}course_modules cm + INNER JOIN {$CFG->prefix}course_modules_completion cmc ON cmc.coursemoduleid=cm.id + WHERE + cm.course=? AND cmc.userid=?",array($this->course->id,$userid)); + + // Reindex by cm id + $alldata=array(); + if($alldatabycmc) { + foreach($alldatabycmc as $data) { + $alldata[$data->coursemoduleid]=$data; + } + } + + // Get the module info and build up condition info for each one + if(empty($modinfo)) { + $modinfo=get_fast_modinfo($this->course,$userid); + } + foreach($modinfo->cms as $othercm) { + if(array_key_exists($othercm->id,$alldata)) { + $data=$alldata[$othercm->id]; + } else { + // Row not present counts as 'not complete' + $data=new StdClass; + $data->id=0; + $data->coursemoduleid=$othercm->id; + $data->userid=$userid; + $data->completionstate=0; + $data->viewed=0; + $data->timemodified=0; + } + $SESSION->completioncache[$this->course->id][$othercm->id]=$data; + } + $SESSION->completioncache[$this->course->id]['updated']=time(); + + if(!isset($SESSION->completioncache[$this->course->id][$cm->id])) { + $this->internal_systemerror("Unexpected error: course-module {$cm->id} could not be found on course {$this->course->id}"); + } + return $SESSION->completioncache[$this->course->id][$cm->id]; + } else { + // Get single record + $data=$DB->get_record('course_modules_completion',array('coursemoduleid'=>$cm->id,'userid'=>$userid)); + if($data==false) { + // Row not present counts as 'not complete' + $data=new StdClass; + $data->id=0; + $data->coursemoduleid=$cm->id; + $data->userid=$userid; + $data->completionstate=0; + $data->viewed=0; + $data->timemodified=0; + } + + // Put in cache + if($currentuser) { + $SESSION->completioncache[$this->course->id][$cm->id]=$data; + // For single updates, only set date if it was empty before + if(empty($SESSION->completioncache[$this->course->id]['updated'])) { + $SESSION->completioncache[$this->course->id]['updated']=time(); + } + } + } + + return $data; + } + + /** + * Updates completion data for a particular coursemodule and user (user is + * determined from $data). + *
+ * (Internal function. Not private, so we can unit-test it.) + * + * @param object $cm Activity + * @param object $data Data about completion for that user + */ + function internal_set_data($cm,$data) { + global $USER,$SESSION,$DB; + if($data->id) { + // Has real (nonzero) id meaning that a database row exists + $DB->update_record('course_modules_completion',$data); + } else { + // Didn't exist before, needs creating + $data->id=$DB->insert_record('course_modules_completion',$data); + } + if($data->userid==$USER->id) { + $SESSION->completioncache[$cm->course][$cm->id]=$data; + } + } + + /** + * Obtains a list of activities for which completion is enabled on the + * course. The list is ordered by the section order of those activities. + * @param array $modinfo For unit testing only, supply the value + * here. Otherwise the method calls get_fast_modinfo + * @return array Array from $cmid => $cm of all activities with completion enabled, + * empty array if none + */ + public function get_activities($modinfo=null) { + global $DB; + + // Obtain those activities which have completion turned on + $withcompletion=$DB->get_records_select('course_modules','course='.$this->course->id. + ' AND completion<>'.COMPLETION_TRACKING_NONE); + if(count($withcompletion)==0) { + return array(); + } + + // Use modinfo to get section order and also add in names + if(empty($modinfo)) { + $modinfo=get_fast_modinfo($this->course); + } + $result=array(); + foreach($modinfo->sections as $sectioncms) { + foreach($sectioncms as $cmid) { + if(array_key_exists($cmid,$withcompletion)) { + $result[$cmid]=$withcompletion[$cmid]; + $result[$cmid]->modname=$modinfo->cms[$cmid]->modname; + $result[$cmid]->name=$modinfo->cms[$cmid]->name; + } + } + } + + return $result; + } + + /** + * Gets list of users in a course whose progress is tracked for display on the + * progress report. + * @param bool $sortfirstname True to sort with firstname + * @param int $groupid Optionally restrict to groupid + * @return array Array of user objects containing id, firstname, lastname (empty if none) + */ + function internal_get_tracked_users($sortfirstname,$groupid=0) { + global $CFG,$DB; + if(!empty($CFG->progresstrackedroles)) { + $roles=explode(',',$CFG->progresstrackedroles); + } else { + // This causes it to default to everyone (if there is no student role) + $roles=array(); + } + $users=get_role_users($roles,get_context_instance(CONTEXT_COURSE,$this->course->id),true, + 'u.id,u.firstname,u.lastname', + $sortfirstname ? 'u.firstname ASC' : 'u.lastname ASC',true,$groupid); + $users=$users ? $users : array(); // In case it returns false + return $users; + } + + /** + * Obtains progress information across a course for all users on that course, or + * for all users in a specific group. Intended for use when displaying progress. + *
+ * This includes only users who, in course context, have one of the roles for + * which progress is tracked (the progresstrackedroles admin option). + *
+ * Users are included (in the first array) even if they do not have
+ * completion progress for any course-module.
+ *
+ * @param bool $sortfirstname If true, sort by first name, otherwise sort by
+ * last name
+ * @param int $groupid Group ID or 0 (default)/false for all groups
+ * @return Array of user objects (like mdl_user id, firstname, lastname)
+ * containing an additional ->progress array of coursemoduleid => completionstate
+ */
+ public function get_progress_all($sortfirstname=false,$groupid=0) {
+ global $CFG,$DB;
+
+ // Get list of applicable users
+ $users=$this->internal_get_tracked_users($sortfirstname,$groupid);
+
+ // Get progress information for these users in groups of 1,000 (if needed)
+ // to avoid making the SQL IN too long
+ $result=array();
+ $userids=array();
+ foreach($users as $user) {
+ $userids[]=$user->id;
+ $result[$user->id]=$user;
+ $result[$user->id]->progress=array();
+ }
+
+ for($i=0;$i
+ * (Internal function. Not private, so we can unit-test it.)
+ *
+ * @param grade_item &$item
+ * @param grade_grade &$grade
+ * @return int Completion state e.g. COMPLETION_INCOMPLETE
+ */
+ function internal_get_grade_state(&$item,&$grade) {
+ if(!$grade) {
+ return COMPLETION_INCOMPLETE;
+ }
+ // Conditions to show pass/fail:
+ // a) Grade has pass mark (default is 0.00000 which is boolean true so be careful)
+ // b) Grade is visible (neither hidden nor hidden-until)
+ if($item->gradepass && $item->gradepass>0.000009 && !$item->hidden) {
+ // Use final grade if set otherwise raw grade
+ $score=!is_null($grade->finalgrade) ? $grade->finalgrade : $grade->rawgrade;
+
+ // We are displaying and tracking pass/fail
+ if($score>=$item->gradepass) {
+ return COMPLETION_COMPLETE_PASS;
+ } else {
+ return COMPLETION_COMPLETE_FAIL;
+ }
+ } else {
+ // Not displaying pass/fail, but we know grade exists b/c we got here
+ return COMPLETION_COMPLETE;
+ }
+ }
+
+ /**
+ * This temporary function is intended to be replaced once a Moodle exception
+ * system is agreed. Code that used to call this function should instead
+ * throw an exception, so this function should be deleted. The function is
+ * only used internally.
+ *
+ * This is to be used only for system errors (things that shouldn't happen)
+ * and not user-level errors.
+ *
+ * @param string $error Error string (will not be displayed to user unless
+ * debugging is enabled)
+ */
+ function internal_systemerror($error) {
+ global $CFG;
+ debugging($error,DEBUG_ALL);
+ print_error('err_system','completion',$CFG->wwwroot.'/course/view.php?id='.$this->course->id);
+ }
+}
+
+
+?>
Index: lang/en_utf8/help/completion/completionusegrade.html
===================================================================
RCS file: lang/en_utf8/help/completion/completionusegrade.html
diff -N lang/en_utf8/help/completion/completionusegrade.html
--- /dev/null 1 Jan 1970 00:00:00 -0000
+++ lang/en_utf8/help/completion/completionusegrade.html 1 Jan 1970 00:00:00 -0000
@@ -0,0 +1,47 @@
+
+When this option is turned on, students have to get a grade on the activity in
+order to complete it. For example, a quiz would be
+marked completed as soon as the user submits it.
+
+It does not matter how well the student did. Getting any grade will mark
+the activity completed.
+
+It is possible to distinguish between 'pass' and 'fail' grades so that the
+activity becomes 'completed, passed' or 'completed, not passed' instead of just
+'completed'. These results show a different icon and alternative text.
+
+To set this up, you need to specify the pass value for this activity's
+individual grade:
+
+Once you have done this, anybody submitting the quiz will receive either the
+pass or fail completion icon. If the quiz can be taken multiple times, the
+completion icon will automatically update whenever the grade does.
+
+There is one limitation: this only works if grades are immediately visible to
+students. The grade must be neither permanently hidden, nor hidden until a certain
+date. If a grade is hidden then only the standard 'completed' state will be
+displayed - even once the hidden date has passed.
+ '.get_string('err_nousers','completion').'
+This optional field lets you associate a date with the activity. Dates do not
+affect the behaviour of the system (for example, you can still complete an
+activity after the date passes) and are not shown to students.
+They are displayed only when viewing the progress report.
+
+Teachers can use the dates on the progress report to help determine whether
+or not certain students might be falling behind. They could then decide to
+contact students and offer assistance.
+
+There is no need to complete this field unless you want a date to show in the
+progress report.
+ ';
+ echo ''.get_string('completionreport','completion').'';
+ echo '
+The forum provides three special options which you can require in order that
+it counts as completed.
+
+You can tick more than one option if necessary; this means both conditions
+must be met before the forum counts as completed. For example, if you
+require 2 discussions and 10 'discussions or replies', then any combination
+of 10 discussions/replies will be sufficient provided that it includes at least
+2 discussions. (10 new discussions; 2 new discussions and 8 replies; or
+somewhere in between.)
+
+If this option is turned on, the system will track whether students have
+completed the activity. Activity completion is shown to students beside each
+activity, and can also be viewed (for all their students) by teachers.
+
+There are three values for this option:
+
+Some types of activity do not support automatic conditions, so you can only
+choose Off or Manual.
+
+If at least one person has completed an activity, completion options are
+'locked'. This is because changing these options may result in
+unexpected behaviour.
+
+If somebody has ticked an activity as
+manually completed, and you then set it to automatic completion, the activity will
+become unticked - very confusing for the student who had already ticked it!
+
+It is best not to unlock options unless you are sure it won't cause problems
+- for example, if you know that students don't have access to the course yet,
+so it will only be staff who have marked the activity completed when testing.
+
+Once you unlock options and then click
+'Save changes', all completion information for the activity will be deleted and,
+if possible, regenerated according to the new settings.
+
+If you change completion options while a student is logged in, they may not see
+the changes for some minutes.
+Require grade
+
+Distinguishing between pass and fail
+
+
+
+
+
'; // ugh
+ if(count($progress)==0) {
+ print '
';
+
+print '';
+
+print_footer($course);
+?>
Index: lang/en_utf8/completion.php
===================================================================
RCS file: lang/en_utf8/completion.php
diff -N lang/en_utf8/completion.php
--- /dev/null 1 Jan 1970 00:00:00 -0000
+++ lang/en_utf8/completion.php 1 Jan 1970 00:00:00 -0000
@@ -0,0 +1,48 @@
+user, $a->activity: $a->state $a->date';
+$string['progresstrackedroles'] = 'Progress-tracked roles';
+$string['restoringcompletiondata']='Writing completion data';
+$string['saved']='Saved';
+$string['unlockcompletion']='Unlock completion options';
+$string['writingcompletiondata']='Writing completion data';
+?>
Index: lang/en_utf8/help/completion/completionexpected.html
===================================================================
RCS file: lang/en_utf8/help/completion/completionexpected.html
diff -N lang/en_utf8/help/completion/completionexpected.html
--- /dev/null 1 Jan 1970 00:00:00 -0000
+++ lang/en_utf8/help/completion/completionexpected.html 1 Jan 1970 00:00:00 -0000
@@ -0,0 +1,19 @@
+';
+
+ // User heading / sort option
+ print ' ';
+}
+
+// Row for each user
+foreach($progress as $user) {
+ // User name
+ if($csv) {
+ print csv_quote(fullname($user));
+ } else {
+ print '';
+ if($firstnamesort) {
+ print
+ get_string('firstname').' / '.
+ get_string('lastname').'';
+ } else {
+ print ''.
+ get_string('firstname').' / '.
+ get_string('lastname');
+ }
+ print ' ';
+}
+
+// Activities
+foreach($activities as $activity) {
+ $activity->datepassed = $activity->completionexpected && $activity->completionexpected <= time();
+ $activity->datepassedclass=$activity->datepassed ? 'completion-expired' : '';
+
+ if($activity->completionexpected) {
+ $datetext=userdate($activity->completionexpected,get_string('strftimedate','langconfig'));
+ } else {
+ $datetext='';
+ }
+
+ if($csv) {
+ print ','.csv_quote($activity->name).','.csv_quote($datetext);
+ } else {
+ print ''.
+ ''.
+ ' ';
+ }
+}
+
+if($csv) {
+ print "\n";
+} else {
+ print '
'.
+ format_string($activity->name).'';
+ if($activity->completionexpected) {
+ print ' ';
+ }
+}
+
+if($csv) {
+ exit;
+}
+print ''.fullname($user).' ';
+ }
+
+ // Progress for each activity
+ foreach($activities as $activity) {
+
+ // Get progress information and state
+ if(array_key_exists($activity->id,$user->progress)) {
+ $progress=$user->progress[$activity->id];
+ $state=$progress->completionstate;
+ $date=userdate($progress->timemodified);
+ } else {
+ $state=COMPLETION_INCOMPLETE;
+ $date='';
+ }
+
+ // Work out how it corresponds to an icon
+ $completiontype=
+ ($activity->completion==COMPLETION_TRACKING_AUTOMATIC ? 'auto' : 'manual').
+ '-';
+ switch($state) {
+ case COMPLETION_INCOMPLETE : $completiontype.='n'; break;
+ case COMPLETION_COMPLETE : $completiontype.='y'; break;
+ case COMPLETION_COMPLETE_PASS : $completiontype.='pass'; break;
+ case COMPLETION_COMPLETE_FAIL : $completiontype.='fail'; break;
+ }
+
+ $describe=get_string('completion-alt-'.$completiontype,'completion');
+ $a=new StdClass;
+ $a->state=$describe;
+ $a->date=$date;
+ $a->user=fullname($user);
+ $a->activity=$activity->name;
+ $fulldescribe=get_string('progress-title','completion',$a);
+
+ if($csv) {
+ print ','.csv_quote($describe).','.csv_quote($date);
+ } else {
+ print ''.
+ ' ';
+ }
+ }
+
+ if($csv) {
+ print "\n";
+ } else {
+ print '
Expect completed on
+
+
+
+
+Completion tracking
+
+
+
+
+Locked completion options
+
+Potential confusion
+
+What happens when you unlock
+
+
+
+
+
+