From 4e781c7b50d92e970764f3e99f23ca4f8d4f6a7e Mon Sep 17 00:00:00 2001 From: sam_marshall Date: Mon, 28 Jul 2008 12:31:29 +0000 Subject: [PATCH] MDL-15498: Completion system --- admin/settings/misc.php | 5 + backup/backuplib.php | 39 +- backup/restorelib.php | 123 ++- course/completion.js | 73 ++ course/edit_form.php | 12 + course/lib.php | 86 ++- course/modedit.php | 29 + course/moodleform_mod.php | 169 ++++- course/report.php | 3 +- course/report/progress/index.php | 201 +++++ course/report/progress/mod.php | 13 + course/report/progress/textrotate.js | 65 ++ course/togglecompletion.php | 57 ++ course/view.php | 9 + lang/en_utf8/completion.php | 48 ++ lang/en_utf8/forum.php | 13 +- lang/en_utf8/help/completion/completion.html | 26 + .../help/completion/completionexpected.html | 19 + .../help/completion/completionlocked.html | 46 ++ .../help/completion/completionusegrade.html | 47 ++ .../help/completion/completionview.html | 14 + lang/en_utf8/help/forum/completion.html | 29 + lib/adminlib.php | 133 ++-- lib/completionlib.php | 710 ++++++++++++++++++ lib/db/access.php | 15 +- lib/db/install.xml | 55 +- lib/db/upgrade.php | 78 +- lib/grade/grade_grade.php | 54 ++ lib/grade/grade_object.php | 15 + lib/moodlelib.php | 68 +- lib/simpletest/completion.manualtest.txt | 167 ++++ lib/simpletest/testcompletionlib.php | 676 +++++++++++++++++ mod/forum/db/install.xml | 5 +- mod/forum/db/upgrade.php | 33 +- mod/forum/lib.php | 135 +++- mod/forum/mod_form.php | 75 +- mod/forum/post.php | 23 +- mod/forum/version.php | 2 +- mod/forum/view.php | 2 + mod/quiz/lib.php | 14 +- mod/quiz/view.php | 6 + pix/i/completion-auto-enabled.gif | Bin 0 -> 304 bytes pix/i/completion-auto-fail.gif | Bin 0 -> 317 bytes pix/i/completion-auto-n.gif | Bin 0 -> 58 bytes pix/i/completion-auto-pass.gif | Bin 0 -> 303 bytes pix/i/completion-auto-y.gif | Bin 0 -> 303 bytes pix/i/completion-manual-enabled.gif | Bin 0 -> 330 bytes pix/i/completion-manual-n.gif | Bin 0 -> 106 bytes pix/i/completion-manual-y.gif | Bin 0 -> 332 bytes theme/standard/styles_layout.css | 71 +- version.php | 2 +- 51 files changed, 3314 insertions(+), 151 deletions(-) create mode 100644 course/completion.js create mode 100644 course/report/progress/index.php create mode 100644 course/report/progress/mod.php create mode 100644 course/report/progress/textrotate.js create mode 100644 course/togglecompletion.php create mode 100644 lang/en_utf8/completion.php create mode 100644 lang/en_utf8/help/completion/completion.html create mode 100644 lang/en_utf8/help/completion/completionexpected.html create mode 100644 lang/en_utf8/help/completion/completionlocked.html create mode 100644 lang/en_utf8/help/completion/completionusegrade.html create mode 100644 lang/en_utf8/help/completion/completionview.html create mode 100644 lang/en_utf8/help/forum/completion.html create mode 100644 lib/completionlib.php create mode 100644 lib/simpletest/completion.manualtest.txt create mode 100644 lib/simpletest/testcompletionlib.php create mode 100644 pix/i/completion-auto-enabled.gif create mode 100644 pix/i/completion-auto-fail.gif create mode 100644 pix/i/completion-auto-n.gif create mode 100644 pix/i/completion-auto-pass.gif create mode 100644 pix/i/completion-auto-y.gif create mode 100644 pix/i/completion-manual-enabled.gif create mode 100644 pix/i/completion-manual-n.gif create mode 100644 pix/i/completion-manual-y.gif diff --git a/admin/settings/misc.php b/admin/settings/misc.php index 875522a504..6df5302ec4 100644 --- a/admin/settings/misc.php +++ b/admin/settings/misc.php @@ -13,6 +13,11 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page $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 diff --git a/backup/backuplib.php b/backup/backuplib.php index 2cc651efec..3b62bfed9a 100644 --- a/backup/backuplib.php +++ b/backup/backuplib.php @@ -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 // diff --git a/backup/restorelib.php b/backup/restorelib.php index 2eb8239404..c822fa4c0f 100644 --- a/backup/restorelib.php +++ b/backup/restorelib.php @@ -752,6 +752,7 @@ define('RESTORE_GROUPS_GROUPINGS', 3); $course->enrolenddate += $restore->course_startdateoffset; } $course->enrolperiod = $course_header->course_enrolperiod; + $course->enablecompletion = isset($course_header->course_enablecompletion) ? $course_header->course_enablecompletion : 0; //Put as last course in category $course->sortorder = $category->sortorder + MAX_COURSES_IN_CATEGORY - 1; @@ -1119,11 +1120,17 @@ define('RESTORE_GROUPS_GROUPINGS', 3); //print_object($course_module); //Debug //Save it to db if ($mod->idnumber) { + $mod->idnumber=backup_todb($mod->idnumber); if (grade_verify_idnumber($mod->idnumber, $restore->course_id)) { $course_module->idnumber = $mod->idnumber; } } + $course_module->completion=$mod->completion; + $course_module->completiongradeitemnumber=backup_todb($mod->completiongradeitemnumber); + $course_module->completionview=$mod->completionview; + $course_module->completionexpected=$mod->completionexpected; + $newidmod = $DB->insert_record("course_modules", $course_module); if ($newidmod) { //save old and new module id @@ -1162,6 +1169,43 @@ define('RESTORE_GROUPS_GROUPINGS', 3); } } } + + // Now that we have IDs for everything, store any completion data + if($status && !empty($info->completiondata)) { + foreach($info->completiondata as $data) { + // Convert cmid + $newcmid=backup_getid($restore->backup_unique_code, 'course_modules', $data->coursemoduleid); + if($newcmid) { + $data->coursemoduleid=$newcmid->new_id; + } else { + if (!defined('RESTORE_SILENTLY')) { + 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 @@ define('RESTORE_GROUPS_GROUPINGS', 3); 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 @@ define('RESTORE_GROUPS_GROUPINGS', 3); $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 @@ define('RESTORE_GROUPS_GROUPINGS', 3); 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 @@ define('RESTORE_GROUPS_GROUPINGS', 3); } } /// 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 @@ define('RESTORE_GROUPS_GROUPINGS', 3); } /// 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; - - $DB->insert_record('role_names', $rolename); - } + $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); } } } } + } return true; } diff --git a/course/completion.js b/course/completion.js new file mode 100644 index 0000000000..bf33e203e0 --- /dev/null +++ b/course/completion.js @@ -0,0 +1,73 @@ +var completion_strsaved; + +function completion_init() { + var toggles=YAHOO.util.Dom.getElementsByClassName('togglecompletion', 'form'); + for(var i=0;i0.999) { + var pos=YAHOO.util.Dom.getXY(form.image); + pos[0]+=20; // Icon size + 4px border + YAHOO.util.Dom.setStyle(form.saved,'display','block'); + YAHOO.util.Dom.setXY(form.saved,pos); + } + setTimeout(function() { completion_update_animation(form,opacity-0.1); },100); +} + +function completion_handle_failure(o) { + alert('An error occurred when attempting to connect to our server. The tick mark will not be saved.\n\n('+ + o.status+' '+o.statusText+')'); +} + +function completion_toggle(e) { + YAHOO.util.Event.preventDefault(e); + YAHOO.util.Connect.asyncRequest('POST','togglecompletion.php', + {success:completion_handle_response,failure:completion_handle_failure,scope:this}, + 'id='+this.cmid+'&completionstate='+this.otherState+'&fromajax=1'); +} + +YAHOO.util.Event.onDOMReady(completion_init); diff --git a/course/edit_form.php b/course/edit_form.php index 706e20ac2b..c1fec3f97b 100644 --- a/course/edit_form.php +++ b/course/edit_form.php @@ -350,6 +350,18 @@ class course_edit_form extends moodleform { $languages += get_list_of_languages(); $mform->addElement('select', 'lang', get_string('forcelanguage'), $languages); +//-------------------------------------------------------------------------------- + require_once($CFG->libdir.'/completionlib.php'); + if(completion_info::is_enabled_for_site()) { + $mform->addElement('header','', get_string('progress','completion')); + $mform->addElement('select', 'enablecompletion', get_string('completion','completion'), + array(0=>get_string('completiondisabled','completion'), 1=>get_string('completionenabled','completion'))); + $mform->setDefault('enablecompletion',1); + } else { + $mform->addElement('hidden', 'enablecompletion'); + $mform->setDefault('enablecompletion',0); + } + //-------------------------------------------------------------------------------- if (has_capability('moodle/site:config', $systemcontext) && ((!empty($course->requested) && $CFG->restrictmodulesfor == 'requested') || $CFG->restrictmodulesfor == 'all')) { $mform->addElement('header', '', get_string('restrictmodules')); diff --git a/course/lib.php b/course/lib.php index 78425fd7dd..5d06e752cd 100644 --- a/course/lib.php +++ b/course/lib.php @@ -1,6 +1,7 @@ libdir.'/completionlib.php'); define('COURSE_MAX_LOG_DISPLAY', 150); // days define('COURSE_MAX_LOGS_PER_PAGE', 1000); // records @@ -1276,7 +1277,7 @@ function set_section_visible($courseid, $sectionnumber, $visibility) { /** * Prints a section full of activity modules */ -function print_section($course, $section, $mods, $modnamesused, $absolute=false, $width="100%") { +function print_section($course, $section, $mods, $modnamesused, $absolute=false, $width="100%", $hidecompletion=false) { global $CFG, $USER, $DB; static $initialised; @@ -1365,7 +1366,7 @@ function print_section($course, $section, $mods, $modnamesused, $absolute=false, $extra = ''; if (!empty($modinfo->cms[$modnumber]->extra)) { - $extra = $modinfo->cms[$modnumber]->extra; + $extra = $modinfo->cms[$modnumber]->extra; } if ($mod->modname == "label") { @@ -1453,6 +1454,66 @@ function print_section($course, $section, $mods, $modnamesused, $absolute=false, echo '  '; echo make_editing_buttons($mod, $absolute, true, $mod->indent, $section->section); } + + // Completion + $completioninfo=new completion_info($course); + $completion=$hidecompletion + ? COMPLETION_TRACKING_NONE + : $completioninfo->is_enabled($mod); + if($completion!=COMPLETION_TRACKING_NONE) { + $completiondata=$completioninfo->get_data($mod,true); + $completionicon=''; + if($isediting) { + switch($completion) { + case COMPLETION_TRACKING_MANUAL : + $completionicon='manual-enabled'; break; + case COMPLETION_TRACKING_AUTOMATIC : + $completionicon='auto-enabled'; break; + default: // wtf + } + } else if($completion==COMPLETION_TRACKING_MANUAL) { + switch($completiondata->completionstate) { + case COMPLETION_INCOMPLETE: + $completionicon='manual-n'; break; + case COMPLETION_COMPLETE: + $completionicon='manual-y'; break; + } + } else { // Automatic + switch($completiondata->completionstate) { + case COMPLETION_INCOMPLETE: + $completionicon='auto-n'; break; + case COMPLETION_COMPLETE: + $completionicon='auto-y'; break; + case COMPLETION_COMPLETE_PASS: + $completionicon='auto-pass'; break; + case COMPLETION_COMPLETE_FAIL: + $completionicon='auto-fail'; break; + } + } + if($completionicon) { + $imgsrc=$CFG->pixpath.'/i/completion-'.$completionicon.'.gif'; + $imgalt=get_string('completion-alt-'.$completionicon,'completion'); + if($completion==COMPLETION_TRACKING_MANUAL && !$isediting) { + $imgtitle=get_string('completion-title-'.$completionicon,'completion'); + $newstate= + $completiondata->completionstate==COMPLETION_COMPLETE + ? COMPLETION_INCOMPLETE + : COMPLETION_COMPLETE; + // In manual mode the icon is a toggle form. + echo " +
+ + + +
"; + } else { + // In auto mode, or when editing, the icon is just an image + echo " +$imgalt"; + } + } + } + echo "\n"; } @@ -2228,6 +2289,27 @@ function set_coursemodule_idnumber($id, $idnumber) { global $DB; return $DB->set_field("course_modules", "idnumber", $idnumber, array("id"=>$id)); } + +function set_coursemodule_completion($id, $completion) { + global $DB; + return $DB->set_field("course_modules", "completion", $completion, array('id'=>$id)); +} + +function set_coursemodule_completionview($id, $completionview) { + global $DB; + return $DB->set_field("course_modules", "completionview", $completionview, array('id'=>$id)); +} + +function set_coursemodule_completiongradeitemnumber($id, $completiongradeitemnumber) { + global $DB; + return $DB->set_field("course_modules", "completiongradeitemnumber", $completiongradeitemnumber, array('id'=>$id)); +} + +function set_coursemodule_completionexpected($id, $completionexpected) { + global $DB; + return $DB->set_field("course_modules", "completionexpected", $completionexpected, array('id'=>$id)); +} + /** * $prevstateoverrides = true will set the visibility of the course module * to what is defined in visibleold. This enables us to remember the current diff --git a/course/modedit.php b/course/modedit.php index 8d4ce927bb..7aafb57837 100644 --- a/course/modedit.php +++ b/course/modedit.php @@ -118,6 +118,10 @@ $form->instance = $cm->instance; $form->return = $return; $form->update = $update; + $form->completion = $cm->completion; + $form->completionview = $cm->completionview; + $form->completionexpected = $cm->completionexpected; + $form->completionusegrade = is_null($cm->completiongradeitemnumber) ? 0 : 1; if ($items = grade_item::fetch_all(array('itemtype'=>'mod', 'itemmodule'=>$form->modulename, 'iteminstance'=>$form->instance, 'courseid'=>$COURSE->id))) { @@ -241,6 +245,19 @@ if (!isset($fromform->name)) { //label $fromform->name = $fromform->modulename; } + + if (!isset($fromform->completion)) { + $fromform->completion=COMPLETION_DISABLED; + } + if (!isset($fromform->completionview)) { + $fromform->completionview=COMPLETION_VIEW_NOT_REQUIRED; + } + + // Convert the 'use grade' checkbox into a grade-item number: 0 if + // checked, null if not + $fromform->completiongradeitemnumber = + isset($fromform->completionusegrade) && $fromform->completionusegrade + ? 0 : null; if (!empty($fromform->update)) { @@ -262,6 +279,18 @@ set_coursemodule_groupingid($fromform->coursemodule, $fromform->groupingid); set_coursemodule_groupmembersonly($fromform->coursemodule, $fromform->groupmembersonly); + // Handle completion settings. If necessary, wipe existing completion + // data first. + if(!empty($fromform->completionunlocked)) { + $completion=new completion_info($course); + $completion->reset_all_state($cm); + } + set_coursemodule_completion($fromform->coursemodule, $fromform->completion); + set_coursemodule_completionview($fromform->coursemodule, $fromform->completionview); + set_coursemodule_completionexpected($fromform->coursemodule, $fromform->completionexpected); + set_coursemodule_completiongradeitemnumber( + $fromform->coursemodule,$fromform->completiongradeitemnumber); + if (isset($fromform->cmidnumber)) { //label // set cm idnumber set_coursemodule_idnumber($fromform->coursemodule, $fromform->cmidnumber); diff --git a/course/moodleform_mod.php b/course/moodleform_mod.php index 52710e9000..eedc6d67b8 100644 --- a/course/moodleform_mod.php +++ b/course/moodleform_mod.php @@ -32,6 +32,11 @@ class moodleform_mod extends moodleform { * List of modform features */ var $_features; + + /** + * @var array Custom completion-rule elements, if enabled + */ + var $_customcompletionelements; function moodleform_mod($instance, $section, $cm) { $this->_instance = $instance; @@ -117,6 +122,56 @@ class moodleform_mod extends moodleform { $mform->removeElement('groupingid'); } } + + // Completion: If necessary, freeze fields + $completion=new completion_info($COURSE); + if($completion->is_enabled()) { + // If anybody has completed the activity, these options will be 'locked' + $completedcount = empty($this->_cm) + ? 0 + : $completion->count_user_data($this->_cm); + + $freeze=false; + if(!$completedcount) { + if($mform->elementExists('unlockcompletion')) { + $mform->removeElement('unlockcompletion'); + } + } else { + // Has the element been unlocked? + if($mform->exportValue('unlockcompletion')) { + // Yes, add in warning text and set the hidden variable + $mform->insertElementBefore( + $mform->createElement('static','completedunlocked', + get_string('completedunlocked','completion'), + get_string('completedunlockedtext','completion')), + 'unlockcompletion'); + $mform->removeElement('unlockcompletion'); + $mform->getElement('completionunlocked')->setValue(1); + } else { + // No, add in the warning text with the count (now we know + // it) before the unlock button + $mform->insertElementBefore( + $mform->createElement('static','completedwarning', + get_string('completedwarning','completion'), + get_string('completedwarningtext','completion',$completedcount)), + 'unlockcompletion'); + $mform->setHelpButton('completedwarning', array('completionlocked', get_string('help_completionlocked', 'completion'), 'completion')); + + $freeze=true; + } + } + + if($freeze) { + $mform->freeze('completion'); + if($mform->elementExists('completionview')) { + $mform->freeze('completionview'); // don't use hardFreeze or checkbox value gets lost + } + if($mform->elementExists('completionusegrade')) { + $mform->freeze('completionusegrade'); + } + $mform->freeze($this->_customcompletionelements); + } + } } // form verification @@ -149,6 +204,15 @@ class moodleform_mod extends moodleform { $errors['cmidnumber'] = get_string('idnumbertaken'); } } + + // Completion: Don't let them choose automatic completion without turning + // on some conditions + if(array_key_exists('completion',$data) && $data['completion']==COMPLETION_TRACKING_AUTOMATIC) { + if(empty($data['completionview']) && empty($data['completionusegrade']) && + !$this->completion_rule_enabled($data)) { + $errors['completion']=get_string('badautocompletion','completion'); + } + } return $errors; } @@ -171,12 +235,23 @@ class moodleform_mod extends moodleform { /** * Adds all the standard elements to a form to edit the settings for an activity module. * - * @param mixed array or object describing supported features - groups, groupings, groupmembersonly, etc. + * @param mixed $features array or object describing supported features - groups, groupings, groupmembersonly, etc. + * @param string $modname Name of module e.g. 'label' */ - function standard_coursemodule_elements($features=null){ + function standard_coursemodule_elements($features=null,$modname=null){ global $COURSE, $CFG, $DB; $mform =& $this->_form; + // Guess module name if not supplied + if(!$modname) { + $matches=array(); + if(!preg_match('/^mod_([^_]+)_mod_form$/',$this->_formname,$matches)) { + debugging('Use $modname parameter or rename form to mod_xx_mod_form, where xx is name of your module'); + error('Unknown module name for form'); + } + $modname=$matches[1]; + } + // deal with legacy $supportgroups param if ($features === true or $features === false) { $groupmode = $features; @@ -216,6 +291,10 @@ class moodleform_mod extends moodleform { if (!isset($this->_features->idnumber)) { $this->_features->idnumber = true; } + + if(!isset($this->_features->defaultcompletion)) { + $this->_features->defaultcompletion = true; + } $outcomesused = false; if (!empty($CFG->enableoutcomes) and $this->_features->outcomes) { @@ -269,8 +348,94 @@ class moodleform_mod extends moodleform { $mform->addElement('select', 'gradecat', get_string('gradecategory', 'grades'), $categories); } + // Conditional activities: completion tracking section + require_once($CFG->libdir.'/completionlib.php'); + $completion=new completion_info($COURSE); + if($completion->is_enabled()) { + $mform->addElement('header', '', get_string('activitycompletion', 'completion')); + + // Unlock button for if people have completed it (will + // be removed in definition_after_data if they haven't) + $mform->addElement('submit','unlockcompletion',get_string('unlockcompletion','completion')); + $mform->registerNoSubmitButton('unlockcompletion'); + $mform->addElement('hidden','completionunlocked',0); + + $mform->addElement('select', 'completion', get_string('completion','completion'), + array(COMPLETION_TRACKING_NONE=>get_string('completion_none','completion'), + COMPLETION_TRACKING_MANUAL=>get_string('completion_manual','completion'))); + $mform->setHelpButton('completion', array('completion', get_string('help_completion', 'completion'), 'completion')); + $mform->setDefault('completion',$this->_features->defaultcompletion + ? COMPLETION_TRACKING_MANUAL + : COMPLETION_TRACKING_NONE); + + // Automatic completion once you view it + $gotcompletionoptions=false; + if(plugin_supports('mod',$modname,FEATURE_COMPLETION_TRACKS_VIEWS)) { + $mform->addElement('checkbox', 'completionview', get_string('completionview','completion'), + get_string('completionview_text','completion')); + $mform->setHelpButton('completionview', array('completionview', get_string('help_completionview', 'completion'), 'completion')); + $mform->disabledIf('completionview','completion','ne',COMPLETION_TRACKING_AUTOMATIC); + $gotcompletionoptions=true; + } + + // Automatic completion once it's graded + if(plugin_supports('mod',$modname,FEATURE_GRADE_HAS_GRADE)) { + $mform->addElement('checkbox', 'completionusegrade', get_string('completionusegrade','completion'), + get_string('completionusegrade_text','completion')); + $mform->setHelpButton('completionusegrade', array('completionusegrade', get_string('help_completionusegrade', 'completion'), 'completion')); + $mform->disabledIf('completionusegrade','completion','ne',COMPLETION_TRACKING_AUTOMATIC); + $gotcompletionoptions=true; + } + + // Automatic completion according to module-specific rules + $this->_customcompletionelements = $this->add_completion_rules(); + foreach($this->_customcompletionelements as $element) { + $mform->disabledIf($element,'completion','ne',COMPLETION_TRACKING_AUTOMATIC); + } + + $gotcompletionoptions = $gotcompletionoptions || + count($this->_customcompletionelements)>0; + + // Automatic option only appears if possible + if($gotcompletionoptions) { + $mform->getElement('completion')->addOption( + get_string('completion_automatic','completion'), + COMPLETION_TRACKING_AUTOMATIC); + } + + // Completion expected at particular date? (For progress tracking) + $mform->addElement('date_selector', 'completionexpected', get_string('completionexpected','completion'), array('optional'=>true)); + $mform->setHelpButton('completionexpected', array('completionexpected', get_string('help_completionexpected', 'completion'), 'completion')); + $mform->disabledIf('completionexpected','completion','eq',COMPLETION_TRACKING_NONE); + } + $this->standard_hidden_coursemodule_elements(); } + + /** + * Can be overridden to add custom completion rules if the module wishes + * them. If overriding this, you should also override completion_rule_enabled. + *

+ * 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; diff --git a/course/report.php b/course/report.php index 8c8c118555..9cf29228a9 100644 --- a/course/report.php +++ b/course/report.php @@ -11,7 +11,8 @@ require_login($course->id); - require_capability('moodle/site:viewreports', get_context_instance(CONTEXT_COURSE, $course->id)); + $context=get_context_instance(CONTEXT_COURSE, $course->id); + require_capability('moodle/site:viewreports', $context); $strreports = get_string('reports'); diff --git a/course/report/progress/index.php b/course/report/progress/index.php new file mode 100644 index 0000000000..2f52a68ca8 --- /dev/null +++ b/course/report/progress/index.php @@ -0,0 +1,201 @@ +get_record('course',array('id'=>required_param('course',PARAM_INT))); +if(!$course) { + error('Specified course not found'); +} + +// Sort (default lastname, optionally firstname) +$sort=optional_param('sort','',PARAM_ALPHA); +$firstnamesort=$sort=='firstname'; + +// CSV format +$csv=optional_param('format','',PARAM_ALPHA)==='csv'; + +function csv_quote($value) { + $tl=textlib_get_instance(); + + $value=$tl->specialtoascii($value); + return '"'.str_replace('"',"'",$value).'"'; +} + +require_login($course->id); + +// Check basic permission +$context=get_context_instance(CONTEXT_COURSE,$course->id); +require_capability('moodle/course:viewprogress',$context); + +// Get group mode +$group=groups_get_course_group($course,true); // Supposed to verify group +if($group===0 && $course->groupmode==SEPARATEGROUPS) { + require_capability('moodle/site:accessallgroups',$context); +} + +// Get data on activities and progress of all users, and give error if we've +// nothing to display (no users or no activities) +$reportsurl=$CFG->wwwroot.'/course/report.php?id='.$course->id; +$completion=new completion_info($course); +$activities=$completion->get_activities(); +if(count($activities)==0) { + print_error('err_noactivities','completion',$reportsurl); +} +$progress=$completion->get_progress_all($firstnamesort,$group); + +if($csv) { + header('Content-Type: text/csv; charset=ISO-8859-1'); + header('Content-Disposition: attachment; filename=progress.'. + preg_replace('/[^a-z0-9-]/','_',strtolower($course->shortname)).'.csv'); +} else { + // Use SVG to draw sideways text if supported + $svgcleverness=check_browser_version('Firefox',2.0) && !$USER->screenreader; + + // Navigation and header + $strreports = get_string("reports"); + $strcompletion = get_string('completionreport','completion'); + $navlinks = array(); + $navlinks[] = array('name' => $strreports, 'link' => "../../report.php?id=$course->id", 'type' => 'misc'); + $navlinks[] = array('name' => $strcompletion, 'link' => null, 'type' => 'misc'); + if($svgcleverness) { + require_js(array('yui_yahoo','yui_event','yui_dom')); + } + print_header($strcompletion,$course->fullname,build_navigation($navlinks)); + if($svgcleverness) { + print ''; + } + + // Handle groups (if enabled) + groups_print_course_menu($course,$CFG->wwwroot.'/course/report/progress/?course='.$course->id); +} + +// Okay, let's draw the table of progress info, + +// Start of table +if(!$csv) { + print '
'; // ugh + if(count($progress)==0) { + print '

'.get_string('err_nousers','completion').'

'; + print '

'.get_string('continue').'

'; + print_footer($course); + exit; + } + print ''; + + // User heading / sort option + 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 ''; +} + +// Row for each user +foreach($progress as $user) { + // User name + if($csv) { + print csv_quote(fullname($user)); + } else { + print ''; + } + + // 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 ''; + } +} + +if($csv) { + exit; +} +print '
'; + if($firstnamesort) { + print + get_string('firstname').' / '. + get_string('lastname').''; + } else { + print ''. + get_string('firstname').' / '. + get_string('lastname'); + } + print ''. + ''. + ''.
+            get_string('modulename',$activity->modname).' '. + format_string($activity->name).''; + if($activity->completionexpected) { + print '
'.$datetext.'
'; + } + print '
'.fullname($user).''. + ''.$describe.'
'; + +print ''; + +print_footer($course); +?> diff --git a/course/report/progress/mod.php b/course/report/progress/mod.php new file mode 100644 index 0000000000..94231009c6 --- /dev/null +++ b/course/report/progress/mod.php @@ -0,0 +1,13 @@ +is_enabled() && has_capability('moodle/course:viewprogress',$context)) { + echo '

'; + echo ''.get_string('completionreport','completion').''; + echo '

'; + } +?> diff --git a/course/report/progress/textrotate.js b/course/report/progress/textrotate.js new file mode 100644 index 0000000000..de79152e9d --- /dev/null +++ b/course/report/progress/textrotate.js @@ -0,0 +1,65 @@ +var SVGNS='http://www.w3.org/2000/svg',XLINKNS='http://www.w3.org/1999/xlink'; + +function textrotate_make_svg(el) +{ + var string=el.firstChild.nodeValue; + + // Create SVG + var svg=document.createElementNS(SVGNS,'svg'); + svg.setAttribute('version','1.1'); + var width=(el.offsetHeight*9)/8; + svg.setAttribute('width',width); + svg.setAttribute('height',el.offsetWidth+20); + + var text=document.createElementNS(SVGNS,'text'); + svg.appendChild(text); + text.setAttribute('x',el.offsetWidth); + text.setAttribute('y',-el.offsetHeight/4); + text.setAttribute('text-anchor','end'); + text.setAttribute('transform','rotate(90)'); + text.appendChild(document.createTextNode(string)); + + // Is there an icon near the text? + var icon=el.parentNode.firstChild; + if(icon.nodeName.toLowerCase()=='img') { + el.parentNode.removeChild(icon); + var image=document.createElementNS(SVGNS,'image'); + var iconx=el.offsetHeight/4; + if(iconx>width-16) iconx=width-16; + image.setAttribute('x',iconx); + image.setAttribute('y',el.offsetWidth+4); + image.setAttribute('width',16); + image.setAttribute('height',16); + image.setAttributeNS(XLINKNS,'href',icon.src); + svg.appendChild(image); + } + + // Replace original content with this new SVG + el.parentNode.insertBefore(svg,el); + el.parentNode.removeChild(el); +} + +function textrotate_init() { + var elements=YAHOO.util.Dom.getElementsByClassName('completion-activityname', 'span'); + for(var i=0;ilibdir.'/completionlib.php'); + +// Parameters +$cmid=required_param('id',PARAM_INT); +$targetstate=required_param('completionstate',PARAM_INT); +switch($targetstate) { + case COMPLETION_COMPLETE: + case COMPLETION_INCOMPLETE: + break; + default: + error('Unsupported completion state'); +} +$fromajax=optional_param('fromajax',0,PARAM_INT); + +function error_or_ajax($message) { + global $fromajax; + if($fromajax) { + print $message; + exit; + } else { + error($message); + } +} + +// Get course-modules entry +if(!($cm=$DB->get_record('course_modules',array('id'=>$cmid)))) { + error_or_ajax('Activity ID unknown'); +} + +if(!($course=$DB->get_record('course',array('id'=>$cm->course)))) { + error_or_ajax('Missing course (database corrupt?)'); +} + +// Check user is logged in +require_login($course); + +// Check completion state is manual +if($cm->completion!=COMPLETION_TRACKING_MANUAL) { + error_or_ajax('Activity does not provide manual completion tracking'); +} + +// Now change state +$completion=new completion_info($course); +$completion->update_state($cm,$targetstate); + +// And redirect back to course +if($fromajax) { + print 'OK'; +} else { + redirect('view.php?id='.$course->id); +} +?> diff --git a/course/view.php b/course/view.php index 8d66fc82a1..264ff2166c 100644 --- a/course/view.php +++ b/course/view.php @@ -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 '
'; diff --git a/lang/en_utf8/completion.php b/lang/en_utf8/completion.php new file mode 100644 index 0000000000..9df67eb69f --- /dev/null +++ b/lang/en_utf8/completion.php @@ -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'; +?> diff --git a/lang/en_utf8/forum.php b/lang/en_utf8/forum.php index 9d68ac70f2..fc23a1ecde 100644 --- a/lang/en_utf8/forum.php +++ b/lang/en_utf8/forum.php @@ -40,6 +40,15 @@ $string['cannotupdaterate'] = 'Could not update an old rating ($a[0] = $a[1])'; $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['editedby'] = 'Edited by $a->name - original submission $a->date'; $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'; diff --git a/lang/en_utf8/help/completion/completion.html b/lang/en_utf8/help/completion/completion.html new file mode 100644 index 0000000000..352492ef4a --- /dev/null +++ b/lang/en_utf8/help/completion/completion.html @@ -0,0 +1,26 @@ +

Completion tracking

+ +

+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: +

+ +
    +
  • Off. Completion information is not tracked for this activity.
  • +
  • Manual. Students can tick a box beside the activity to indicate + that they have completed it.
  • +
  • Automatic. Based on conditions set on this screen (immediately below the + dropdown), the activity will automatically be marked completed.
  • +
+ +

+Some types of activity do not support automatic conditions, so you can only +choose Off or Manual. +

+ + diff --git a/lang/en_utf8/help/completion/completionexpected.html b/lang/en_utf8/help/completion/completionexpected.html new file mode 100644 index 0000000000..28ff989c94 --- /dev/null +++ b/lang/en_utf8/help/completion/completionexpected.html @@ -0,0 +1,19 @@ +

Expect completed on

+ +

+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. +

\ No newline at end of file diff --git a/lang/en_utf8/help/completion/completionlocked.html b/lang/en_utf8/help/completion/completionlocked.html new file mode 100644 index 0000000000..7f3338c58c --- /dev/null +++ b/lang/en_utf8/help/completion/completionlocked.html @@ -0,0 +1,46 @@ +

Locked completion options

+ +

+If at least one person has completed an activity, completion options are +'locked'. This is because changing these options may result in +unexpected behaviour. +

+ +

Potential confusion

+ +

+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. +

+ +

What happens when you unlock

+ +

+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 the new completion option is manual, everyone will be set to 'not completed' +regardless of any previous setting.
  • +
  • If it is automatic then, depending on the options chosen, the system may or may +not be able to construct a correct current value for everyone.
      +
    • The 'viewed' requirement will not work - even if a student has viewed the +activity before, it will not be marked completed until they view it again.
    • +
    • Most other options will be recalculated successfully.
    • +
  • +
+ +

+If you change completion options while a student is logged in, they may not see +the changes for some minutes. +

+ diff --git a/lang/en_utf8/help/completion/completionusegrade.html b/lang/en_utf8/help/completion/completionusegrade.html new file mode 100644 index 0000000000..2f6e48dbf9 --- /dev/null +++ b/lang/en_utf8/help/completion/completionusegrade.html @@ -0,0 +1,47 @@ +

Require grade

+ +

+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. +

+ +

Distinguishing between pass and fail

+ +

+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: +

+ +
    +
  1. Go to the course gradebook by clicking the 'Grades' link on the course administration +block.
  2. +
  3. From the 'Choose an action' dropdown, pick 'Categories and items'.
  4. +
  5. Click the Edit icon next to the grade item for this activity.
  6. +
  7. Turn on 'Show Advanced'.
  8. +
  9. Type a grade value (e.g. 5.0) in the 'Grade to pass' box.
  10. +
+ +

+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. +

\ No newline at end of file diff --git a/lang/en_utf8/help/completion/completionview.html b/lang/en_utf8/help/completion/completionview.html new file mode 100644 index 0000000000..b54888d7ab --- /dev/null +++ b/lang/en_utf8/help/completion/completionview.html @@ -0,0 +1,14 @@ +

Require view

+ +

+When this option is turned on, students have to view the activity in order +to complete it. +

+ +
    +
  • In most cases, clicking the link is enough to 'view' the activity.
  • +
  • You should usually not turn on the 'view' condition if you have other +requirements - this makes extra work for the server and it's unlikely that a +student could meet any other conditions without viewing the activity.
  • +
+ diff --git a/lang/en_utf8/help/forum/completion.html b/lang/en_utf8/help/forum/completion.html new file mode 100644 index 0000000000..b253a5723a --- /dev/null +++ b/lang/en_utf8/help/forum/completion.html @@ -0,0 +1,29 @@ +

Forum completion

+ +

+The forum provides three special options which you can require in order that +it counts as completed. +

+ +
    +
  • User must post discussions or replies: the student must post a certain +number of times to the forum, and it doesn't matter whether those posts are +new discussions or replies to existing discussions.
  • +
  • User must create discussions: the student most post a certain number of +new discussions.
  • +
  • User must post replies: the student must post a certain number of replies +in existing discussions.
  • +
+ +

+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.) +

+ + + + diff --git a/lib/adminlib.php b/lib/adminlib.php index 91643f8559..21eaeed7a1 100644 --- a/lib/adminlib.php +++ b/lib/adminlib.php @@ -201,7 +201,7 @@ function get_db_directories() { $dbdirs[] = $CFG->dirroot.'/'.$CFG->admin.'/report/'.$plugin.'/db'; } } - + /// Now quiz report plugins (mod/quiz/report/xxx/db) if ($plugins = get_list_of_plugins('mod/quiz/report', 'db')) { foreach ($plugins as $plugin) { @@ -1883,7 +1883,7 @@ class admin_setting_configtext extends admin_setting { $data = 0; } // $data is a string - $validated = $this->validate($data); + $validated = $this->validate($data); if ($validated !== true) { return $validated; } @@ -1944,7 +1944,7 @@ class admin_setting_configtextarea extends admin_setting_configtext { $defaultinfo = $default; if (!is_null($default) and $default !== '') { $defaultinfo = "\n".$default; - } + } return format_admin_setting($this, $this->visiblename, '
', @@ -2221,7 +2221,7 @@ class admin_setting_configmulticheckbox extends admin_setting { } } } - + $options = array(); $defaults = array(); foreach($this->choices as $key=>$description) { @@ -2258,7 +2258,7 @@ class admin_setting_configmulticheckbox extends admin_setting { $return .= '
'; return format_admin_setting($this, $this->visiblename, $return, $this->description, false, '', $defaultinfo, $query); - + } } @@ -2353,7 +2353,7 @@ class admin_setting_configselect extends admin_setting { if (strpos($textlib->strtolower($value), $query) !== false) { return true; } - } + } return false; } @@ -2800,7 +2800,7 @@ class admin_setting_sitesettext extends admin_setting_configtext { function write_setting($data) { global $DB; $data = trim($data); - $validated = $this->validate($data); + $validated = $this->validate($data); if ($validated !== true) { return $validated; } @@ -3388,12 +3388,22 @@ class admin_setting_special_calendar_weekend extends admin_setting { /** - * Graded roles in gradebook + * Admin setting that allows a user to pick appropriate roles for something. */ -class admin_setting_special_gradebookroles extends admin_setting_configmulticheckbox { - function admin_setting_special_gradebookroles() { - parent::admin_setting_configmulticheckbox('gradebookroles', get_string('gradebookroles', 'admin'), - get_string('configgradebookroles', 'admin'), NULL, NULL); +class admin_setting_pickroles extends admin_setting_configmulticheckbox { + private $types; + + /** + * @param string $name Name of config variable + * @param string $visiblename Display name + * @param string $description Description + * @param array $types Array of capabilities (usually moodle/legacy:something) + * which identify roles that will be enabled by default. Default is the + * student role + */ + function admin_setting_pickroles($name, $visiblename, $description,$types=array('moodle/legacy:student')) { + parent::admin_setting_configmulticheckbox($name, $visiblename, $description, NULL, NULL); + $this->types=$types; } function load_choices() { @@ -3418,18 +3428,33 @@ class admin_setting_special_gradebookroles extends admin_setting_configmultichec function get_defaultsetting() { global $CFG; if (empty($CFG->rolesactive)) { - return NULL; + return array(0); } $result = array(); - if ($studentroles = get_roles_with_capability('moodle/legacy:student', CAP_ALLOW)) { - foreach ($studentroles as $studentrole) { - $result[$studentrole->id] = '1'; + foreach($this->types as $capability) { + if ($caproles = get_roles_with_capability($capability, CAP_ALLOW)) { + foreach ($caproles as $caprole) { + if(!in_array($caprole->id,$result)) { + $result[] = $caprole->id; + } + } } } return $result; } } +/** + * Graded roles in gradebook + */ +class admin_setting_special_gradebookroles extends admin_setting_pickroles { + function admin_setting_special_gradebookroles() { + parent::admin_setting_pickroles('gradebookroles', get_string('gradebookroles', 'admin'), + get_string('configgradebookroles', 'admin')); + } +} + + class admin_setting_regradingcheckbox extends admin_setting_configcheckbox { function write_setting($data) { global $CFG, $DB; @@ -3444,45 +3469,17 @@ class admin_setting_regradingcheckbox extends admin_setting_configcheckbox { } return $return; - } + } } /** * Which roles to show on course decription page */ -class admin_setting_special_coursemanager extends admin_setting_configmulticheckbox { +class admin_setting_special_coursemanager extends admin_setting_pickroles { function admin_setting_special_coursemanager() { - parent::admin_setting_configmulticheckbox('coursemanager', get_string('coursemanager', 'admin'), - get_string('configcoursemanager', 'admin'), NULL, NULL); - } - - function load_choices() { - global $DB; - if (is_array($this->choices)) { - return true; - } - if ($roles = $DB->get_records('role',null,'sortorder')) { - $this->choices = array(); - foreach($roles as $role) { - $this->choices[$role->id] = format_string($role->name); - } - return true; - } - return false; - } - - function get_defaultsetting() { - global $CFG; - if (empty($CFG->rolesactive)) { - return NULL; - } - $result = array(); - if ($teacherroles = get_roles_with_capability('moodle/legacy:editingteacher', CAP_ALLOW)) { - foreach ($teacherroles as $teacherrole) { - $result[$teacherrole->id] = '1'; - } - } - return $result; + parent::admin_setting_pickroles('coursemanager', get_string('coursemanager', 'admin'), + get_string('configcoursemanager', 'admin'), + 'moodle/legacy:editingteacher'); } } @@ -3589,7 +3586,7 @@ class admin_setting_gradecat_combo extends admin_setting { $defaultinfo[] = get_string('advanced'); } $defaultinfo = implode(', ', $defaultinfo); - + } else { $defaultinfo = NULL; } @@ -4792,7 +4789,7 @@ function db_replace($search, $replace) { /** * Prints tables of detected plugins, one table per plugin type, - * and prints whether they are part of the standard Moodle + * and prints whether they are part of the standard Moodle * distribution or not. */ function print_plugin_tables() { @@ -4813,7 +4810,7 @@ function print_plugin_tables() { 'scorm', 'survey', 'wiki'); - + $plugins_standard['blocks'] = array('activity_modules', 'admin', 'admin_bookmarks', @@ -4845,7 +4842,7 @@ function print_plugin_tables() { 'tag_flickr', 'tag_youtube', 'tags'); - + $plugins_standard['filter'] = array('activitynames', 'algebra', 'censor', @@ -4872,14 +4869,14 @@ function print_plugin_tables() { $plugins_ondisk['mod'] = get_list_of_plugins('mod', 'db'); $plugins_ondisk['blocks'] = get_list_of_plugins('blocks', 'db'); $plugins_ondisk['filter'] = get_list_of_plugins('filter', 'db'); - + $strstandard = get_string('standard'); $strnonstandard = get_string('nonstandard'); $strmissingfromdisk = '(' . get_string('missingfromdisk') . ')'; $strabouttobeinstalled = '(' . get_string('abouttobeinstalled') . ')'; $html = ''; - + $html .= ''; foreach ($plugins_ondisk as $cat => $list_ondisk) { @@ -4895,8 +4892,8 @@ function print_plugin_tables() { $html .= '\n" . '\n" . '\n\n"; - - $row = 1; + + $row = 1; foreach ($list_ondisk as $k => $plugin) { $status = 'ok'; @@ -4906,15 +4903,15 @@ function print_plugin_tables() { if (!in_array($plugin, $plugins_standard[$cat])) { $standard = 'nonstandard'; $status = 'warning'; - } - + } + // Get real name and full path of plugin $plugin_name = "[[$plugin]]"; - + $plugin_path = "$cat/$plugin"; - + $plugin_name = get_plugin_name($plugin, $cat); - + // Determine if the plugin is about to be installed if ($cat != 'filter' && !in_array($plugin, $plugins_installed[$cat])) { $note = $strabouttobeinstalled; @@ -4930,11 +4927,11 @@ function print_plugin_tables() { // If the plugin was both on disk and in the db, unset the value from the installed plugins list if ($key = array_search($plugin, $plugins_installed[$cat])) { unset($plugins_installed[$cat][$key]); - } - } + } + } // If there are plugins left in the plugins_installed list, it means they are missing from disk - foreach ($plugins_installed[$cat] as $k => $missing_plugin) { + foreach ($plugins_installed[$cat] as $k => $missing_plugin) { // Make sure the plugin really is missing from disk if (!in_array($missing_plugin, $plugins_ondisk[$cat])) { $standard = 'standard'; @@ -4949,15 +4946,15 @@ function print_plugin_tables() { . "\n" . "\n" . "\n\n"; - $row++; + $row++; } } $html .= '
' . get_string('directory') . "' . get_string('name') . "' . get_string('status') . "
?$plugin_name" . ${'str' . $standard} . " $strmissingfromdisk
'; } - + $html .= '
'; - + echo $html; } diff --git a/lib/completionlib.php b/lib/completionlib.php new file mode 100644 index 0000000000..9d3c5cc910 --- /dev/null +++ b/lib/completionlib.php @@ -0,0 +1,710 @@ +id, ->enablecompletion + * @return completion_info + */ + public function completion_info($course) { + $this->course=$course; + } + + /** + * Static function. Determines whether completion is enabled across entire + * site. + * + * @return int COMPLETION_ENABLED (true) if completion is enabled for the site, + * COMPLETION_DISABLED (false) if it's complete + */ + public static function is_enabled_for_site() { + global $CFG; + return $CFG->enablecompletion; + } + + /** + * Checks whether completion is enabled in a particular course and possibly + * activity. + * + * @param object $cm Course-module object. If not specified, returns the course + * completion enable state. + * @return COMPLETION_ENABLED or COMPLETION_DISABLED (==0) in the case of + * site and course; COMPLETION_TRACKING_MANUAL, _AUTOMATIC or _NONE (==0) + * for a course-module. + */ + public function is_enabled($cm=null) { + // First check global completion + global $CFG; + if($CFG->enablecompletion==COMPLETION_DISABLED) { + return COMPLETION_DISABLED; + } + + // Check course completion + if($this->course->enablecompletion==COMPLETION_DISABLED) { + return COMPLETION_DISABLED; + } + + // If there was no $cm and we got this far, then it's enabled + if(!$cm) { + return COMPLETION_ENABLED; + } + + // Return course-module completion value + return $cm->completion; + } + + /** + * Updates (if necessary) the completion state of activity $cm for the given + * user. + *

+ * 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;$iget_in_or_equal(array_slice($userids,$i,$blocksize)); + array_splice($params,0,0,array($this->course->id)); + $rs=$DB->get_recordset_sql(" +SELECT + cmc.* +FROM + {$CFG->prefix}course_modules cm + INNER JOIN {$CFG->prefix}course_modules_completion cmc ON cm.id=cmc.coursemoduleid +WHERE + cm.course=? AND cmc.userid $insql + ",$params); + if(!$rs) { + $this->internal_systemerror('Failed to obtain completion progress'); + } + foreach($rs as $progress) { + $result[$progress->userid]->progress[$progress->coursemoduleid]=$progress; + } + $rs->close(); + } + + return $result; + } + + public function inform_grade_changed($cm,&$item,&$grade,$deleted) { + // Bail out now if completion is not enabled for course-module, grade + // is not used to compute completion, or this is a different numbered + // grade + if(!$this->is_enabled($cm) || + is_null($cm->completiongradeitemnumber) || + $item->itemnumber!=$cm->completiongradeitemnumber) { + return; + } + + // What is the expected result based on this grade? + if($deleted) { + // Grade being deleted, so only change could be to make it incomplete + $possibleresult=COMPLETION_INCOMPLETE; + } else { + $possibleresult=$this->internal_get_grade_state($item,$grade); + } + + // OK, let's update state based on this + $this->update_state($cm,$possibleresult,$grade->userid); + } + + /** + * Calculates the completion state that would result from a graded item + * (where grade-based completion is turned on) based on the actual grade + * and settings. + *

+ * (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); + } +} + + +?> diff --git a/lib/db/access.php b/lib/db/access.php index 434cc5dfce..95c4c530b0 100644 --- a/lib/db/access.php +++ b/lib/db/access.php @@ -539,6 +539,19 @@ $moodle_capabilities = array( ) ), + 'moodle/course:viewprogress' => array( + + 'riskbitmask' => RISK_PERSONAL, + + 'captype' => 'read', + 'contextlevel' => CONTEXT_COURSE, + 'legacy' => array( + 'teacher' => CAP_ALLOW, + 'editingteacher' => CAP_ALLOW, + 'admin' => CAP_ALLOW + ) + ), + 'moodle/course:viewhiddencourses' => array( 'captype' => 'read', @@ -830,7 +843,7 @@ $moodle_capabilities = array( 'admin' => CAP_ALLOW ) ), - + //capabilities designed for the new message system configuration 'moodle/user:editmessageprofile' => array( diff --git a/lib/db/install.xml b/lib/db/install.xml index 78ed9cc9ff..c6d1102b2e 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -73,7 +73,9 @@ - + + @@ -131,7 +133,7 @@ - +
@@ -146,7 +148,14 @@ - + + + + + @@ -160,7 +169,33 @@
- +
+ + + + + + + + + + + + + + + + + +
+ @@ -1680,11 +1715,11 @@ - - - - -
+ + + + + @@ -1790,4 +1825,4 @@ - + \ No newline at end of file diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index 3fc26decaa..0c4613e283 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -191,7 +191,7 @@ function xmldb_main_upgrade($oldversion=0) { $result = $DB->delete_records_select('role_names', $DB->sql_isempty('role_names', 'name', false, false)); upgrade_main_savepoint($result, 2008070300); } - + if ($result && $oldversion < 2008070700) { if (isset($CFG->defaultuserroleid) and isset($CFG->guestroleid) and $CFG->defaultuserroleid == $CFG->guestroleid) { // guest can not be selected in defaultuserroleid! @@ -347,6 +347,82 @@ function xmldb_main_upgrade($oldversion=0) { upgrade_main_savepoint($result, 2008072400); } + if ($result && $oldversion < 2008072800) { + + /// Define field enablecompletion to be added to course + $table = new xmldb_table('course'); + $field = new xmldb_field('enablecompletion'); + $field->set_attributes(XMLDB_TYPE_INTEGER, '1', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null, null, '0', 'defaultrole'); + + /// Launch add field enablecompletion + if(!$dbman->field_exists($table,$field)) { + $dbman->add_field($table, $field); + } + + /// Define field completion to be added to course_modules + $table = new xmldb_table('course_modules'); + $field = new xmldb_field('completion'); + $field->set_attributes(XMLDB_TYPE_INTEGER, '1', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null, null, '0', 'groupmembersonly'); + + /// Launch add field completion + if(!$dbman->field_exists($table,$field)) { + $dbman->add_field($table, $field); + } + + /// Define field completiongradeitemnumber to be added to course_modules + $field = new xmldb_field('completiongradeitemnumber'); + $field->set_attributes(XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, null, null, null, null, null, 'completion'); + + /// Launch add field completiongradeitemnumber + if(!$dbman->field_exists($table,$field)) { + $dbman->add_field($table, $field); + } + + /// Define field completionview to be added to course_modules + $field = new xmldb_field('completionview'); + $field->set_attributes(XMLDB_TYPE_INTEGER, '1', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null, null, '0', 'completiongradeitemnumber'); + + /// Launch add field completionview + if(!$dbman->field_exists($table,$field)) { + $dbman->add_field($table, $field); + } + + /// Define field completionexpected to be added to course_modules + $field = new xmldb_field('completionexpected'); + $field->set_attributes(XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null, null, '0', 'completionview'); + + /// Launch add field completionexpected + if(!$dbman->field_exists($table,$field)) { + $dbman->add_field($table, $field); + } + + /// Define table course_modules_completion to be created + $table = new xmldb_table('course_modules_completion'); + if(!$dbman->table_exists($table)) { + + /// Adding fields to table course_modules_completion + $table->addFieldInfo('id', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, XMLDB_SEQUENCE, null, null, null); + $table->addFieldInfo('coursemoduleid', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null, null, null); + $table->addFieldInfo('userid', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null, null, null); + $table->addFieldInfo('completionstate', XMLDB_TYPE_INTEGER, '1', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null, null, null); + $table->addFieldInfo('viewed', XMLDB_TYPE_INTEGER, '1', XMLDB_UNSIGNED, null, null, null, null, null); + $table->addFieldInfo('timemodified', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null, null, null); + + /// Adding keys to table course_modules_completion + $table->addKeyInfo('primary', XMLDB_KEY_PRIMARY, array('id')); + + /// Adding indexes to table course_modules_completion + $table->addIndexInfo('coursemoduleid', XMLDB_INDEX_NOTUNIQUE, array('coursemoduleid')); + $table->addIndexInfo('userid', XMLDB_INDEX_NOTUNIQUE, array('userid')); + + /// Launch create table for course_modules_completion + $dbman->create_table($table); + } + + /// Main savepoint reached + upgrade_main_savepoint($result, 2008072800); + } + /* * TODO: diff --git a/lib/grade/grade_grade.php b/lib/grade/grade_grade.php index da34086acd..2f21c483f6 100644 --- a/lib/grade/grade_grade.php +++ b/lib/grade/grade_grade.php @@ -735,5 +735,59 @@ class grade_grade extends grade_object { $this->rawgrademax = grade_floatval($this->rawgrademax); return parent::update($source); } + + /** + * Used to notify the completion system (if necessary) that a user's grade + * has changed. + * @param bool deleted True if grade was actually deleted + */ + function notify_changed($deleted) { + // Ignore during restore + global $restore; + if(!empty($restore->backup_unique_code)) { + return; + } + global $CFG,$COURSE,$DB; + require_once($CFG->libdir.'/completionlib.php'); + + // Use $COURSE if available otherwise get it via item fields + if(!empty($COURSE)) { + $course=$COURSE; + } else { + $this->load_grade_item(); + $course=get_record('course','id',$grade_item->courseid); + } + + // Bail out immediately if completion is not enabled for course + $completion=new completion_info($course); + if(!$completion->is_enabled()) { + return; + } + + // Get the grade item and course-module which we will need + $this->load_grade_item(); + if($this->grade_item->itemtype!='mod') { + return; + } + $cm=$DB->get_record_sql(" +SELECT + cm.*,m.name AS modname +FROM + {$CFG->prefix}modules m + INNER JOIN {$CFG->prefix}course_modules cm ON m.id=cm.module +WHERE + m.name=? AND cm.instance=? AND cm.course=?", + array($this->grade_item->itemmodule,$this->grade_item->iteminstance, + $this->grade_item->courseid)); + if(!$cm) { + debugging("Couldn't find course-module for module + '{$this->grade_item->itemmodule}', instance '{$this->grade_item->iteminstance}', + course '{$this->grade_item->courseid}'"); + return; + } + + // Pass information on to completion system + $completion->inform_grade_changed($cm,$this->grade_item,$this,$deleted); + } } ?> diff --git a/lib/grade/grade_object.php b/lib/grade/grade_object.php index 177a7cb3c9..1caf043fcb 100644 --- a/lib/grade/grade_object.php +++ b/lib/grade/grade_object.php @@ -173,6 +173,7 @@ abstract class grade_object { global $DB; if ($datas = $DB->get_records_select($table, $wheresql, $params)) { + $result = array(); foreach($datas as $data) { $instance = new $classname(); @@ -182,6 +183,7 @@ abstract class grade_object { return $result; } else { + return false; } } @@ -215,6 +217,7 @@ abstract class grade_object { $DB->insert_record($this->table.'_history', $data); } + $this->notify_changed(false); return true; } @@ -244,6 +247,7 @@ abstract class grade_object { $data->userlogged = $USER->id; $DB->insert_record($this->table.'_history', $data); } + $this->notify_changed(true); return true; } else { @@ -306,6 +310,7 @@ abstract class grade_object { $DB->insert_record($this->table.'_history', $data); } + $this->notify_changed(false); return $this->id; } @@ -344,5 +349,15 @@ abstract class grade_object { } } } + + /** + * Called immediately after the object data has been inserted, updated, or + * deleted in the database. Default does nothing, can be overridden to + * hook in special behaviour. + * + * @param bool $deleted + */ + function notify_changed($deleted) { + } } ?> diff --git a/lib/moodlelib.php b/lib/moodlelib.php index e7edc4fc06..659bccb782 100644 --- a/lib/moodlelib.php +++ b/lib/moodlelib.php @@ -3345,7 +3345,7 @@ function delete_course($courseorid, $showfeedback = true) { $courseid = $courseorid; if (!$course = $DB->get_record('course', array('id'=>$courseid))) { return false; - } + } } // frontpage course can not be deleted!! @@ -5938,6 +5938,48 @@ function get_list_of_plugins($plugin='mod', $exclude='', $basedir='') { return $plugins; } +// Feature constants +/** True if module can provide a grade */ +define('FEATURE_GRADE_HAS_GRADE','grade_has_grade'); +/** True if module has code to track whether somebody viewed it */ +define('FEATURE_COMPLETION_TRACKS_VIEWS','completion_tracks_views'); +/** True if module has custom completion rules */ +define('FEATURE_COMPLETION_HAS_RULES','completion_has_rules'); + +/** + * Checks whether a plugin supports a specified feature. + * + * @param string $type Plugin type e.g. 'mod' + * @param string $name Plugin name + * @param string $feature Feature code (FEATURE_xx constant) + * @return Feature result (false if not supported, usually true but may have + * other feature-specific value otherwise) + */ +function plugin_supports($type,$name,$feature) { + global $CFG; + switch($type) { + case 'mod' : + $file=$CFG->dirroot.'/mod/'.$name.'/lib.php'; + $function=$name.'_supports'; + break; + default: + throw new Exception('Unsupported plugin type ('.$type.')'); + } + + // Load library and look for function + require_once($file); + if(function_exists($function)) { + // Function exists, so just return function result + return $function($feature); + } else { + switch($feature) { + // If some features can also be checked in other ways + // for legacy support, this could be added here + default: return false; + } + } +} + /** * Returns true if the current version of PHP is greater that the specified one. * @@ -5949,13 +5991,13 @@ function check_php_version($version='5.2.0') { } /** - * Checks to see if is the browser operating system matches the specified + * Checks to see if is the browser operating system matches the specified * brand. - * + * * Known brand: 'Windows','Linux','Macintosh','SGI','SunOS','HP-UX' * * @uses $_SERVER - * @param string $brand The operating system identifier being tested + * @param string $brand The operating system identifier being tested * @return bool true if the given brand below to the detected operating system */ function check_browser_operating_system($brand) { @@ -5966,8 +6008,8 @@ function check_php_version($version='5.2.0') { if (preg_match("/$brand/i", $_SERVER['HTTP_USER_AGENT'])) { return true; } - - return false; + + return false; } /** @@ -6572,7 +6614,7 @@ function shorten_text($text, $ideal=30, $exact = false) { // if the words shouldn't be cut in the middle... if (!$exact) { // ...search the last occurance of a space... - for ($k=strlen($truncate);$k>0;$k--) { + for ($k=strlen($truncate);$k>0;$k--) { if (!empty($truncate[$k]) && ($char = $truncate[$k])) { if ($char == '.' or $char == ' ') { $breakpos = $k+1; @@ -6582,23 +6624,23 @@ function shorten_text($text, $ideal=30, $exact = false) { break; // character boundary. } } - } + } - if (isset($breakpos)) { + if (isset($breakpos)) { // ...and cut the text in this position $truncate = substr($truncate, 0, $breakpos); - } - } + } + } // add the defined ending to the text - $truncate .= $ending; + $truncate .= $ending; // close all unclosed html-tags foreach ($open_tags as $tag) { $truncate .= ''; } - return $truncate; + return $truncate; } diff --git a/lib/simpletest/completion.manualtest.txt b/lib/simpletest/completion.manualtest.txt new file mode 100644 index 0000000000..198bb5ddec --- /dev/null +++ b/lib/simpletest/completion.manualtest.txt @@ -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. + + diff --git a/lib/simpletest/testcompletionlib.php b/lib/simpletest/testcompletionlib.php new file mode 100644 index 0000000000..2c920afa7a --- /dev/null +++ b/lib/simpletest/testcompletionlib.php @@ -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)); + } +} +?> \ No newline at end of file diff --git a/mod/forum/db/install.xml b/mod/forum/db/install.xml index 9ca99574ef..800f96cf28 100644 --- a/mod/forum/db/install.xml +++ b/mod/forum/db/install.xml @@ -23,7 +23,10 @@ - + + + + diff --git a/mod/forum/db/upgrade.php b/mod/forum/db/upgrade.php index 74898d8cec..2238402ac2 100644 --- a/mod/forum/db/upgrade.php +++ b/mod/forum/db/upgrade.php @@ -21,6 +21,8 @@ function xmldb_forum_upgrade($oldversion=0) { global $CFG, $THEME, $DB; + $dbman = $DB->get_manager(); // loads ddl manager and xmldb classes + $result = true; /// And upgrade begins here. For each one, you'll need one @@ -75,7 +77,7 @@ function xmldb_forum_upgrade($oldversion=0) { upgrade_mod_savepoint($result, 2007101512, 'forum'); } - + if ($result and $oldversion < 2008072401) { $eventdata = new object(); $eventdata->modulename = 'forum'; @@ -85,6 +87,35 @@ function xmldb_forum_upgrade($oldversion=0) { upgrade_mod_savepoint($result, 2008072401, 'forum'); } + if ($result && $oldversion < 2008072800) { + /// Define field completiondiscussions to be added to forum + $table = new XMLDBTable('forum'); + $field = new XMLDBField('completiondiscussions'); + $field->setAttributes(XMLDB_TYPE_INTEGER, '9', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null, null, '0', 'draft'); + + /// Launch add field completiondiscussions + if(!$dbman->field_exists($table,$field)) { + $dbman->add_field($table, $field); + } + + $field = new XMLDBField('completionreplies'); + $field->setAttributes(XMLDB_TYPE_INTEGER, '9', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null, null, '0', 'completiondiscussions'); + + /// Launch add field completionreplies + if(!$dbman->field_exists($table,$field)) { + $dbman->add_field($table, $field); + } + + /// Define field completionposts to be added to forum + $field = new XMLDBField('completionposts'); + $field->setAttributes(XMLDB_TYPE_INTEGER, '9', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null, null, '0', 'completionreplies'); + + /// Launch add field completionposts + if(!$dbman->field_exists($table,$field)) { + $dbman->add_field($table, $field); + } + upgrade_mod_savepoint($result, 2008072800, 'forum'); + } return $result; diff --git a/mod/forum/lib.php b/mod/forum/lib.php index 85231d5403..e9ce9048d5 100644 --- a/mod/forum/lib.php +++ b/mod/forum/lib.php @@ -29,7 +29,7 @@ define ('FORUM_AGGREGATE_SUM', 5); /// STANDARD FUNCTIONS /////////////////////////////////////////////////////////// -/** +/** * Code to be executed when a module is installed * now is just used to register the module as message provider */ @@ -209,6 +209,83 @@ function forum_delete_instance($id) { } +/** + * Indicates API features that the forum supports. + * + * @param string $feature + * @return mixed True if yes (some features may use other values) + */ +function forum_supports($feature) { + switch($feature) { + case FEATURE_COMPLETION_TRACKS_VIEWS: return true; + case FEATURE_COMPLETION_HAS_RULES: return true; + default: return false; + } +} + + +/** + * Obtains the automatic completion state for this forum based on any conditions + * in forum settings. + * + * @param object $course Course + * @param object $cm Course-module + * @param int $userid User ID + * @param bool $type Type of comparison (or/and; can be used as return value if no conditions) + * @return bool True if completed, false if not. (If no conditions, then return + * value depends on comparison type) + */ +function forum_get_completion_state($course,$cm,$userid,$type) { + global $CFG,$DB; + + // Get forum details + if(!($forum=$DB->get_record('forum',array('id'=>$cm->instance)))) { + throw new Exception("Can't find forum {$cm->instance}"); + } + + $result=$type; // Default return value + + $postcountparams=array('userid'=>$userid,'forumid'=>$forum->id); + $postcountsql=" +SELECT + COUNT(1) +FROM + {$CFG->prefix}forum_posts fp + INNER JOIN {$CFG->prefix}forum_discussions fd ON fp.discussion=fd.id +WHERE + fp.userid=:userid AND fd.forum=:forumid"; + + if($forum->completiondiscussions) { + $value = $forum->completiondiscussions <= + $DB->count_records('forum_discussions',array('forum'=>$forum->id,'userid'=>$userid)); + if($type==COMPLETION_AND) { + $result=$result && $value; + } else { + $result=$result || $value; + } + } + if($forum->completionreplies) { + $value = $forum->completionreplies <= + $DB->get_field_sql( $postcountsql.' AND fp.parent<>0',$postcountparams); + if($type==COMPLETION_AND) { + $result=$result && $value; + } else { + $result=$result || $value; + } + } + if($forum->completionposts) { + $value = $forum->completionposts <= $DB->get_field_sql($postcountsql,$postcountparams); + if($type==COMPLETION_AND) { + $result=$result && $value; + } else { + $result=$result || $value; + } + } + + return $result; +} + + /** * Function to be run periodically according to the moodle cron * Finds all posts that have yet to be mailed out, and mails them @@ -3981,10 +4058,14 @@ function forum_add_discussion($discussion,&$message) { /** - * + * Deletes a discussion and handles all associated cleanup. + * @param object $discussion Discussion to delete + * @param bool $fulldelete True when deleting entire forum + * @param object $course Course (required if fulldelete is false) + * @param object $cm Course-module (required if fulldelete is false) + * @param object $forum Forum (required if fulldelete is false) */ -function forum_delete_discussion($discussion, $fulldelete=false) { -// $discussion is a discussion record object +function forum_delete_discussion($discussion, $fulldelete=false,$course=null,$cm=null,$forum=null) { global $DB; $result = true; @@ -3992,10 +4073,7 @@ function forum_delete_discussion($discussion, $fulldelete=false) { foreach ($posts as $post) { $post->course = $discussion->course; $post->forum = $discussion->forum; - if (! $DB->delete_records("forum_ratings", array("post" => "$post->id"))) { - $result = false; - } - if (! forum_delete_post($post, $fulldelete)) { + if (! forum_delete_post($post, 'ignore',$course, $cm, $forum, $fulldelete)) { $result = false; } } @@ -4007,26 +4085,45 @@ function forum_delete_discussion($discussion, $fulldelete=false) { $result = false; } + // Update completion state if we are tracking completion based on number of posts + $completion=new completion_info($course); + if(!$fulldelete && // But don't bother when deleting whole thing + $completion->is_enabled($cm)==COMPLETION_TRACKING_AUTOMATIC && + ($forum->completiondiscussions || $forum->completionreplies || $forum->completionposts)) { + $completion->update_state($cm,COMPLETION_INCOMPLETE,$discussion->userid); + } + return $result; } /** - * + * Deletes a single forum post. + * @param object $post Forum post object + * @param mixed $children Whether to delete children. If false, returns false + * if there are any children (without deleting the post). If true, + * recursively deletes all children. If set to special value 'ignore', deletes + * post regardless of children (this is for use only when deleting all posts + * in a disussion). + * @param object $course Course + * @param object $cm Course-module + * @param object $forum Forum + * @param bool $skipcompletion True to skip updating completion state if it + * would otherwise be updated, i.e. when deleting entire forum anyway. */ -function forum_delete_post($post, $children=false) { +function forum_delete_post($post, $children, $course, $cm, $forum, $skipcompletion=false) { global $DB; - if ($childposts = $DB->get_records('forum_posts', array('parent' => $post->id))) { + if ($children!='ignore' && ($childposts = $DB->get_records('forum_posts', array('parent'=>$post->id)))) { if ($children) { foreach ($childposts as $childpost) { - forum_delete_post($childpost, true); + forum_delete_post($childpost, true, $course, $cm, $forum, $skipcompletion); } } else { return false; } } if ($DB->delete_records("forum_posts", array("id" => $post->id))) { - $DB->delete_records("forum_ratings", array("post" => $post->id)); // Just in case + $DB->delete_records("forum_ratings", array("post" => $post->id)); forum_tp_delete_read_records(-1, $post->id); @@ -4040,6 +4137,14 @@ function forum_delete_post($post, $children=false) { // Just in case we are deleting the last post forum_discussion_update_last_post($post->discussion); + // Update completion state if we are tracking completion based on number of posts + $completion=new completion_info($course); + if(!$skipcompletion && // But don't bother when deleting whole thing + $completion->is_enabled($cm)==COMPLETION_TRACKING_AUTOMATIC && + ($forum->completiondiscussions || $forum->completionreplies || $forum->completionposts)) { + $completion->update_state($cm,COMPLETION_INCOMPLETE,$post->userid); + } + return true; } return false; @@ -4345,8 +4450,8 @@ function forum_user_has_posted($forumid, $did, $userid) { WHERE p.userid = :userid AND d.forum = :forumid"; return $DB->record_exists_sql($sql, array('forumid'=>$forumid,'userid'=>$userid)); } else { - return $DB->record_exists('forum_posts', array('discussion'=>$did,'userid'=>$userid)); - } + return $DB->record_exists('forum_posts', array('discussion'=>$did,'userid'=>$userid)); +} } /** diff --git a/mod/forum/mod_form.php b/mod/forum/mod_form.php index 5a0493a4f1..e3fa1424d0 100644 --- a/mod/forum/mod_form.php +++ b/mod/forum/mod_form.php @@ -13,7 +13,7 @@ class mod_forum_mod_form extends moodleform_mod { $mform->addElement('text', 'name', get_string('forumname', 'forum'), array('size'=>'64')); if (!empty($CFG->formatstringstriptags)) { - $mform->setType('name', PARAM_TEXT); + $mform->setType('name', PARAM_TEXT); } else { $mform->setType('name', PARAM_CLEAN); } @@ -178,7 +178,80 @@ class mod_forum_mod_form extends moodleform_mod { $default_values['ratingtime']= ($default_values['assesstimestart'] && $default_values['assesstimefinish']) ? 1 : 0; } + + // Set up the completion checkboxes which aren't part of standard data. + // We also make the default value (if you turn on the checkbox) for those + // numbers to be 1, this will not apply unless checkbox is ticked. + $default_values['completiondiscussionsenabled']= + !empty($default_values['completiondiscussions']) ? 1 : 0; + if(empty($default_values['completiondiscussions'])) { + $default_values['completiondiscussions']=1; + } + $default_values['completionrepliesenabled']= + !empty($default_values['completionreplies']) ? 1 : 0; + if(empty($default_values['completionreplies'])) { + $default_values['completionreplies']=1; + } + $default_values['completionpostsenabled']= + !empty($default_values['completionposts']) ? 1 : 0; + if(empty($default_values['completionposts'])) { + $default_values['completionposts']=1; + } } + function add_completion_rules() { + $mform =& $this->_form; + + $group=array(); + $group[] =& $mform->createElement('checkbox', 'completionpostsenabled', '', get_string('completionposts','forum')); + $group[] =& $mform->createElement('text', 'completionposts', '', array('size'=>3)); + $mform->setType('completionposts',PARAM_INT); + $mform->addGroup($group, 'completionpostsgroup', get_string('completionpostsgroup','forum'), array(' '), false); + $mform->setHelpButton('completionpostsgroup', array('completion', get_string('completionpostshelp', 'forum'), 'forum')); + $mform->disabledIf('completionposts','completionpostsenabled','notchecked'); + + $group=array(); + $group[] =& $mform->createElement('checkbox', 'completiondiscussionsenabled', '', get_string('completiondiscussions','forum')); + $group[] =& $mform->createElement('text', 'completiondiscussions', '', array('size'=>3)); + $mform->setType('completiondiscussions',PARAM_INT); + $mform->addGroup($group, 'completiondiscussionsgroup', get_string('completiondiscussionsgroup','forum'), array(' '), false); + $mform->setHelpButton('completiondiscussionsgroup', array('completion', get_string('completiondiscussionshelp', 'forum'), 'forum')); + $mform->disabledIf('completiondiscussions','completiondiscussionsenabled','notchecked'); + + $group=array(); + $group[] =& $mform->createElement('checkbox', 'completionrepliesenabled', '', get_string('completionreplies','forum')); + $group[] =& $mform->createElement('text', 'completionreplies', '', array('size'=>3)); + $mform->setType('completionreplies',PARAM_INT); + $mform->addGroup($group, 'completionrepliesgroup', get_string('completionrepliesgroup','forum'), array(' '), false); + $mform->setHelpButton('completionrepliesgroup', array('completion', get_string('completionreplieshelp', 'forum'), 'forum')); + $mform->disabledIf('completionreplies','completionrepliesenabled','notchecked'); + + return array('completiondiscussionsgroup','completionrepliesgroup','completionpostsgroup'); + } + + function completion_rule_enabled($data) { + return (!empty($data['completiondiscussionsenabled']) && $data['completiondiscussions']!=0) || + (!empty($data['completionrepliesenabled']) && $data['completionreplies']!=0) || + (!empty($data['completionpostsenabled']) && $data['completionposts']!=0); + } + + function get_data($slashed=true) { + $data=parent::get_data($slashed); + if(!$data) { + return false; + } + // Turn off completion settings if the checkboxes aren't ticked + $autocompletion=!empty($data->completion) && $data->completion==COMPLETION_TRACKING_AUTOMATIC; + if(empty($data->completiondiscussionsenabled) || !$autocompletion) { + $data->completiondiscussions=0; + } + if(empty($data->completionrepliesenabled) || !$autocompletion) { + $data->completionreplies=0; + } + if(empty($data->completionpostsenabled) || !$autocompletion) { + $data->completionposts=0; + } + return $data; + } } ?> diff --git a/mod/forum/post.php b/mod/forum/post.php index a56bb830fb..02434f62d8 100644 --- a/mod/forum/post.php +++ b/mod/forum/post.php @@ -280,14 +280,15 @@ notice("Sorry, but you are not allowed to delete that discussion!", forum_go_back_to("discuss.php?d=$post->discussion")); } - forum_delete_discussion($discussion); + forum_delete_discussion($discussion,false,$course,$cm,$forum); add_to_log($discussion->course, "forum", "delete discussion", "view.php?id=$cm->id", "$forum->id", $cm->id); - + redirect("view.php?f=$discussion->forum"); - } else if (forum_delete_post($post, has_capability('mod/forum:deleteanypost', $modcontext))) { + } else if (forum_delete_post($post, has_capability('mod/forum:deleteanypost', $modcontext), + $course, $cm, $forum)) { if ($forum->type == 'single') { // Single discussion forums are an exception. We show @@ -402,7 +403,7 @@ add_to_log($discussion->course, "forum", "prune post", "discuss.php?d=$newid", "$post->id", $cm->id); - + redirect(forum_go_back_to("discuss.php?d=$newid")); } else { // User just asked to prune something @@ -554,6 +555,13 @@ } add_to_log($course->id, "forum", "add post", "$discussionurl&parent=$fromform->id", "$fromform->id", $cm->id); + + // Update completion state + $completion=new completion_info($course); + if($completion->is_enabled($cm) && + ($forum->completionreplies || $forum->completionposts)) { + $completion->update_state($cm,COMPLETION_COMPLETE); + } redirect(forum_go_back_to("$discussionurl#p$fromform->id"), $message.$subscribemessage, $timemessage); @@ -605,6 +613,13 @@ $timemessage = 4; } + // Update completion status + $completion=new completion_info($course); + if($completion->is_enabled($cm) && + ($forum->completiondiscussions || $forum->completionposts)) { + $completion->update_state($cm,COMPLETION_COMPLETE); + } + redirect(forum_go_back_to("view.php?f=$fromform->forum"), $message.$subscribemessage, $timemessage); } else { diff --git a/mod/forum/version.php b/mod/forum/version.php index 5fbc77909f..4b38384269 100644 --- a/mod/forum/version.php +++ b/mod/forum/version.php @@ -5,7 +5,7 @@ // This fragment is called by /admin/index.php //////////////////////////////////////////////////////////////////////////////// -$module->version = 2008072401; +$module->version = 2008072800; $module->requires = 2008072401; // Requires this Moodle version $module->cron = 60; diff --git a/mod/forum/view.php b/mod/forum/view.php index cfe7078532..94e9cca6e8 100644 --- a/mod/forum/view.php +++ b/mod/forum/view.php @@ -268,6 +268,8 @@ break; } + $completion=new completion_info($course); + $completion->set_module_viewed($cm); print_footer($course); ?> diff --git a/mod/quiz/lib.php b/mod/quiz/lib.php index 0b310df59c..fc019f3b9a 100644 --- a/mod/quiz/lib.php +++ b/mod/quiz/lib.php @@ -58,7 +58,7 @@ define("QUIZ_MAX_EVENT_LENGTH", 5*24*60*60); // 5 days maximum /// FUNCTIONS /////////////////////////////////////////////////////////////////// -/** +/** * Code to be executed when a module is installed * now is just used to register the module as message provider */ @@ -1210,6 +1210,18 @@ function quiz_num_attempt_summary($quiz, $cm, $returnzero = false, $currentgroup return ''; } +/** + * @param string $feature FEATURE_xx constant for requested feature + * @return bool True if quiz supports feature + */ +function quiz_supports($feature) { + switch($feature) { + case FEATURE_GRADE_HAS_GRADE: return true; + case FEATURE_COMPLETION_TRACKS_VIEWS: return true; + default: return false; + } +} + /** * Returns all other caps used in module */ diff --git a/mod/quiz/view.php b/mod/quiz/view.php index 03962aee3d..20152658c9 100644 --- a/mod/quiz/view.php +++ b/mod/quiz/view.php @@ -373,6 +373,12 @@ // Should we not be seeing if we need to print right-hand-side blocks? finish_page($course); + + // Mark module as viewed (note, we do this here and not in finish_page, + // otherwise the 'not enrolled' error conditions would result in marking + // 'viewed', I think it's better if they don't.) + $completion=new completion_info($course); + $completion->set_module_viewed(cm); // Utility functions ================================================================= diff --git a/pix/i/completion-auto-enabled.gif b/pix/i/completion-auto-enabled.gif new file mode 100644 index 0000000000000000000000000000000000000000..6257ff712c20551b97071fcd5d6b07358c48ec75 GIT binary patch literal 304 zcmZ?wbhEHb6krfwSgOj<(9qD>*x1z6)Y8(@*4Eb1(b3)A-QV9ob?VgV)2GjxHEZ_l z*^3q}TDo-Us#U92uU@@r)27XvH*eXpW!tuGr%s(ZckbMkD_5>xzkd7n?MIIuJ$drv z)vH%;-n@DH_U*fO?>>C^@cHxSpFe;8`t|GY-@pI=|7V~!Q2fcl$i-mKpaU`(pa>9#CmP1xglW9eVDMQ{m4SmnAV!R#Q#y%{(LX!JBgx*8!5RQI_--oz literal 0 HcmV?d00001 diff --git a/pix/i/completion-auto-fail.gif b/pix/i/completion-auto-fail.gif new file mode 100644 index 0000000000000000000000000000000000000000..43d1317d71465b1bfce2018b753240627336f598 GIT binary patch literal 317 zcmZ?wbhEHb6krfwSgONN$I4pI&fdty)y&V|DkRhW)vDa$@S#Q?q7WShD2W zh7C8jZoRdA`<*>|9vnXW=){R<=g+^sdGo{L#~+_O`S|qd=NB)2e);m}_wT=d{`~*{ zpMe6P_>+Z^i@}~j2V@4wPYi5P2kvKi=!mr*TQcdy3jy`sr>6=W)|xOpzr=W=g+=hC z7gK}+gR&vpfwqotc{7GD`%VNW^~nTG>SAMH$&vF`=VB02V`UKW=4TLdmS*JC@)8ks PRpH7{@|bJy$Y2cs6RK?S literal 0 HcmV?d00001 diff --git a/pix/i/completion-auto-n.gif b/pix/i/completion-auto-n.gif new file mode 100644 index 0000000000000000000000000000000000000000..0b2d1babbecb00afd104576229dbe3afdf681338 GIT binary patch literal 58 zcmZ?wbhEHb6krfwXkcXc|NlP&1B2pE7Dg@xMg|=q8z?Toz`!KP(>HV3TmHp!w%qF8 MeDBW&Nf8EX0G#;{q5uE@ literal 0 HcmV?d00001 diff --git a/pix/i/completion-auto-pass.gif b/pix/i/completion-auto-pass.gif new file mode 100644 index 0000000000000000000000000000000000000000..c95ecbffde97c052dfa429683e14cb9fa5fbacb8 GIT binary patch literal 303 zcmZ?wbhEHb6krfwSgOgu7B9x0AkLX6$(1CoGX`^3hYgFxE zT;pI-?`qxX;n5l5*BcozIVo~VO4QWUjCng1g>SD#$J`TWl97x(VIy#L_!qi645eERnJ%a1R=e*gOa|33pYfZ|UUMlJ?> z1|5)LAU`p%`5chX^3V}$J+`E%Gtgt2D-T0a6Q6Zc>&Y%HMJ`3XRZJ&3Y#Z7oo=P-K qo5sw(l`&=kuS4Pyg$WCk*cdn&L}H{EI^~lD8HKZxJSI9aSOWlVVqduc literal 0 HcmV?d00001 diff --git a/pix/i/completion-auto-y.gif b/pix/i/completion-auto-y.gif new file mode 100644 index 0000000000000000000000000000000000000000..c12fd4afca788df9cf567960469e8ba49360548f GIT binary patch literal 303 zcmZ?wbhEHb6krfwSjqqbjEsy-OiU~+EUc`o92^`xJUoJef?{G~^78UpT3XuL+Io6= z`uh4NCMM?Q<}NNSUS3`yAt7O5Vd3H7si~=HX=$06nYp>SQ>ILrJ9qAi6)V=QTeof7 zwxdUno;Y#h%9SfOZ{ECp`}W@a);MPoF-0`SRu0uV4TF{|DPo5ux~#g^`QF zo3dm0kY(59#vpjUfT8}L$ni=Uc?I@2zQxl&p^Rbh&j8u7)@=h^b=&)z(u=uLb rGELk?{U~F?0=|aED;iT4D6=zgF^I-UGju8>2{DOeCwWYCWUvMRp9@x> literal 0 HcmV?d00001 diff --git a/pix/i/completion-manual-enabled.gif b/pix/i/completion-manual-enabled.gif new file mode 100644 index 0000000000000000000000000000000000000000..72dc6358ae8c9020ab43f5ae254e3868c5565932 GIT binary patch literal 330 zcmZ?wbhEHb6krfwSgOWQSy@?CRaISGU0YjQUtizY*x1t2($Ue;+uPgU-#=;6q{)*f z&zd!B?%cVH7A;!2a^{)|0|yQqIdbIGsZ(dpoVjq}!u9LdZ{ECl@7}!! z4<0;x`0&xAM^BzSdH($Qn>TOXzJ2@d-Mfz;KYsrF`Tzg_3{(Y*KUo;L80;BzK&FEH z#K4w*AU@MWN32zKQqjqfCZm&T#Z3hh7F>GDazP??aZrzfVo=i3h0KkcZOj!JxGvvi zGniHK)M>*(b!9ej6)^^(pa?Do7CBC4`F2lPRt63R{>XGuhS^dx68IRy<|#y0NZL!T L1crgVBZD;n8qj#t literal 0 HcmV?d00001 diff --git a/pix/i/completion-manual-n.gif b/pix/i/completion-manual-n.gif new file mode 100644 index 0000000000000000000000000000000000000000..107ca70a21fbe7ca4109a35cdb903bdba51e0627 GIT binary patch literal 106 zcmZ?wbhEHb6krfwIK%(~H*em2^XARx&!7MQ{|^=b5sE)q7`YhO8FWA#kXi<2{faGj z)~1~BT*|RAtMfZc@bl0|Y;89?s-CY}w^pS1W%NAbzq{tQbMAI{pr9kw-_F8d4FIoG BC$Inj literal 0 HcmV?d00001 diff --git a/pix/i/completion-manual-y.gif b/pix/i/completion-manual-y.gif new file mode 100644 index 0000000000000000000000000000000000000000..8b2b28f924d338f77db4a6217da55560370d8915 GIT binary patch literal 332 zcmZ?wbhEHb6krfwSjqqbjEs!T%*-q-EbQ#;+}zv(0sFK?_y;G-7U9e!m(xpq+u3fu(_wIfB z_MJL)>fE_=7cN}5bm`L7t5uUcF}RT6;$ZYXDfJW~%@I literal 0 HcmV?d00001 diff --git a/theme/standard/styles_layout.css b/theme/standard/styles_layout.css index c8e580cd67..bb4586ce3c 100644 --- a/theme/standard/styles_layout.css +++ b/theme/standard/styles_layout.css @@ -91,11 +91,11 @@ h6.main { } #redirect #message { - + } #redirect #continue { - + } /* .clearfix {display: inline-table;} */ @@ -1803,6 +1803,29 @@ border-width:0px; #course-view .section .weekdates { } +#course-view li.activity { + margin-right:20px; /* Space allowed for completion icons if enabled */ + position:relative; +} +#course-view li.activity form.togglecompletion, +#course-view li.activity span.autocompletion { + display:inline; + position:absolute; + right:-20px; + z-index:10; +} +#course-view li.activity form.togglecompletion div { + display:inline; +} +#course-view .completion-saved-display { + position:absolute; + top:0; left:0; + border:1px solid black; + padding: 1px 2px; + background:white; + font-size:0.85em; +} + #course-view ul.section, #site-index ul.section { margin: 0; @@ -1845,7 +1868,7 @@ border-width:0px; padding: 0; } .weeks-format .block_calendar_month .minicalendar th, -.topics-format .block_calendar_month .minicalendar th, +.topics-format .block_calendar_month .minicalendar th, .weeks-format .block_calendar_month .minicalendar td, .topics-format .block_calendar_month .minicalendar td { padding: 0.1em 0 0.1em 1px; @@ -1896,7 +1919,7 @@ border-width:0px; .weeks .right, .topics .right { float: right; -} +} .section .activity img.activityicon { vertical-align:middle; @@ -2277,7 +2300,7 @@ div.allcoursegrades { } #grade-aggregation-help dt { - margin-top: 15px; + margin-top: 15px; } #grade-aggregation-help dd.example { @@ -2684,6 +2707,36 @@ body.notes .notesgroup { border-bottom: 1px solid #555; } +/*** + *** Completion progress report + ***/ + +#course-report-progress-index th, +#course-report-progress-index td { + padding:2px 4px; + font-weight:normal; + border-right: 1px solid #EEE; +} +.completion-expired { + background:#fdd; +} +.completion-expected { + font-size:0.75em; +} +.completion-sortchoice { + font-size:0.75em; + vertical-align:bottom; +} +.completion-progresscell { + text-align:right; +} +.completion-expired .completion-expected { + font-weight:bold; +} +#course-report-progress-index .progress-actions { + text-align:center; +} + /*** *** Logs ***/ @@ -2778,7 +2831,7 @@ body.notes .notesgroup { } -.tabrow0 .here a:link, +.tabrow0 .here a:link, .tabrow0 .here a:visited, .tabrow0 .here a.nolink { position:relative; @@ -4035,17 +4088,17 @@ table.quizreviewsummary td.cell { padding: 4px; } -#mod-quiz-grading table#grading .header .commands +#mod-quiz-grading table#grading .header .commands { display: inline; } -#mod-quiz-grading table#grading .picture +#mod-quiz-grading table#grading .picture { width: 40px; } -#mod-quiz-grading table#grading td +#mod-quiz-grading table#grading td { border-left-width: 1px; border-right-width: 1px; diff --git a/version.php b/version.php index f19efc0501..a98798b883 100644 --- a/version.php +++ b/version.php @@ -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 -- 2.39.5