diff --git a/availability/condition/completion/classes/condition.php b/availability/condition/completion/classes/condition.php index 11f86c82ce9..991af644922 100644 --- a/availability/condition/completion/classes/condition.php +++ b/availability/condition/completion/classes/condition.php @@ -24,6 +24,12 @@ namespace availability_completion; +use cache; +use core_availability\info; +use core_availability\info_module; +use core_availability\info_section; +use stdClass; + defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/completionlib.php'); @@ -36,14 +42,27 @@ require_once($CFG->libdir . '/completionlib.php'); * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class condition extends \core_availability\condition { + + /** @var int previous module cm value used to calculate relative completions */ + public const OPTION_PREVIOUS = -1; + /** @var int ID of module that this depends on */ protected $cmid; + /** @var array IDs of the current module and section */ + protected $selfids; + /** @var int Expected completion type (one of the COMPLETE_xx constants) */ protected $expectedcompletion; + /** @var array Array of previous cmids used to calculate relative completions */ + protected $modfastprevious = []; + + /** @var array Array of cmids previous to each course section */ + protected $sectionfastprevious = []; + /** @var array Array of modules used in these conditions for course */ - protected static $modsusedincondition = array(); + protected static $modsusedincondition = []; /** * Constructor. @@ -58,20 +77,27 @@ class condition extends \core_availability\condition { } else { throw new \coding_exception('Missing or invalid ->cm for completion condition'); } - // Get expected completion. if (isset($structure->e) && in_array($structure->e, - array(COMPLETION_COMPLETE, COMPLETION_INCOMPLETE, - COMPLETION_COMPLETE_PASS, COMPLETION_COMPLETE_FAIL))) { + [COMPLETION_COMPLETE, COMPLETION_INCOMPLETE, + COMPLETION_COMPLETE_PASS, COMPLETION_COMPLETE_FAIL])) { $this->expectedcompletion = $structure->e; } else { throw new \coding_exception('Missing or invalid ->e for completion condition'); } } - public function save() { - return (object)array('type' => 'completion', - 'cm' => $this->cmid, 'e' => $this->expectedcompletion); + /** + * Saves tree data back to a structure object. + * + * @return stdClass Structure object (ready to be made into JSON format) + */ + public function save(): stdClass { + return (object) [ + 'type' => 'completion', + 'cm' => $this->cmid, + 'e' => $this->expectedcompletion, + ]; } /** @@ -84,22 +110,41 @@ class condition extends \core_availability\condition { * @param int $expectedcompletion Expected completion value (COMPLETION_xx) * @return stdClass Object representing condition */ - public static function get_json($cmid, $expectedcompletion) { - return (object)array('type' => 'completion', 'cm' => (int)$cmid, - 'e' => (int)$expectedcompletion); + public static function get_json(int $cmid, int $expectedcompletion): stdClass { + return (object) [ + 'type' => 'completion', + 'cm' => (int)$cmid, + 'e' => (int)$expectedcompletion, + ]; } - public function is_available($not, \core_availability\info $info, $grabthelot, $userid) { + /** + * Determines whether a particular item is currently available + * according to this availability condition. + * + * @see \core_availability\tree_node\update_after_restore + * + * @param bool $not Set true if we are inverting the condition + * @param info $info Item we're checking + * @param bool $grabthelot Performance hint: if true, caches information + * required for all course-modules, to make the front page and similar + * pages work more quickly (works only for current user) + * @param int $userid User ID to check availability for + * @return bool True if available + */ + public function is_available($not, info $info, $grabthelot, $userid): bool { + list($selfcmid, $selfsectionid) = $this->get_selfids($info); + $cmid = $this->get_cmid($info->get_course(), $selfcmid, $selfsectionid); $modinfo = $info->get_modinfo(); $completion = new \completion_info($modinfo->get_course()); - if (!array_key_exists($this->cmid, $modinfo->cms)) { + if (!array_key_exists($cmid, $modinfo->cms) || $modinfo->cms[$cmid]->deletioninprogress) { // If the cmid cannot be found, always return false regardless // of the condition or $not state. (Will be displayed in the // information message.) $allow = false; } else { // The completion system caches its own data so no caching needed here. - $completiondata = $completion->get_data((object)array('id' => $this->cmid), + $completiondata = $completion->get_data((object)['id' => $cmid], $grabthelot, $userid, $modinfo); $allow = true; @@ -128,6 +173,134 @@ class condition extends \core_availability\condition { return $allow; } + /** + * Return current item IDs (cmid and sectionid). + * + * @param info $info + * @return int[] with [0] => cmid/null, [1] => sectionid/null + */ + public function get_selfids(info $info): array { + if (isset($this->selfids)) { + return $this->selfids; + } + if ($info instanceof info_module) { + $cminfo = $info->get_course_module(); + if (!empty($cminfo->id)) { + $this->selfids = [$cminfo->id, null]; + return $this->selfids; + } + } + if ($info instanceof info_section) { + $section = $info->get_section(); + if (!empty($section->id)) { + $this->selfids = [null, $section->id]; + return $this->selfids; + } + + } + return [null, null]; + } + + /** + * Get the cmid referenced in the access restriction. + * + * @param stdClass $course course object + * @param int|null $selfcmid current course-module ID or null + * @param int|null $selfsectionid current course-section ID or null + * @return int|null cmid or null if no referenced cm is found + */ + public function get_cmid(stdClass $course, ?int $selfcmid, ?int $selfsectionid): ?int { + if ($this->cmid > 0) { + return $this->cmid; + } + // If it's a relative completion, load fast browsing. + if ($this->cmid == self::OPTION_PREVIOUS) { + $prevcmid = $this->get_previous_cmid($course, $selfcmid, $selfsectionid); + if ($prevcmid) { + return $prevcmid; + } + } + return null; + } + + /** + * Return the previous CM ID of an specific course-module or course-section. + * + * @param stdClass $course course object + * @param int|null $selfcmid course-module ID or null + * @param int|null $selfsectionid course-section ID or null + * @return int|null + */ + private function get_previous_cmid(stdClass $course, ?int $selfcmid, ?int $selfsectionid): ?int { + $this->load_course_structure($course); + if (isset($this->modfastprevious[$selfcmid])) { + return $this->modfastprevious[$selfcmid]; + } + if (isset($this->sectionfastprevious[$selfsectionid])) { + return $this->sectionfastprevious[$selfsectionid]; + } + return null; + } + + /** + * Loads static information about a course elements previous activities. + * + * Populates two variables: + * - $this->sectionprevious[] course-module previous to a cmid + * - $this->sectionfastprevious[] course-section previous to a cmid + * + * @param stdClass $course course object + */ + private function load_course_structure(stdClass $course): void { + // If already loaded we don't need to do anything. + if (empty($this->modfastprevious)) { + $previouscache = cache::make('availability_completion', 'previous_cache'); + $this->modfastprevious = $previouscache->get("mod_{$course->id}"); + $this->sectionfastprevious = $previouscache->get("sec_{$course->id}"); + } + + if (!empty($this->modfastprevious)) { + return; + } + + if (empty($this->modfastprevious)) { + $this->modfastprevious = []; + $sectionprevious = []; + + $modinfo = get_fast_modinfo($course); + $lastcmid = 0; + foreach ($modinfo->cms as $othercm) { + if ($othercm->deletioninprogress) { + continue; + } + // Save first cm of every section. + if (!isset($sectionprevious[$othercm->section])) { + $sectionprevious[$othercm->section] = $lastcmid; + } + // Load previous to all cms with completion. + if ($othercm->completion == COMPLETION_TRACKING_NONE) { + continue; + } + if ($lastcmid) { + $this->modfastprevious[$othercm->id] = $lastcmid; + } + $lastcmid = $othercm->id; + } + // Fill empty sections index. + $isections = array_reverse($modinfo->get_section_info_all()); + foreach ($isections as $section) { + if (isset($sectionprevious[$section->id])) { + $lastcmid = $sectionprevious[$section->id]; + } else { + $sectionprevious[$section->id] = $lastcmid; + } + } + $this->sectionfastprevious = $sectionprevious; + $previouscache->set("mod_{$course->id}", $this->modfastprevious); + $previouscache->set("sec_{$course->id}", $this->sectionfastprevious); + } + } + /** * Returns a more readable keyword corresponding to a completion state. * @@ -136,7 +309,7 @@ class condition extends \core_availability\condition { * @param int $completionstate COMPLETION_xx constant * @return string Readable keyword */ - protected static function get_lang_string_keyword($completionstate) { + protected static function get_lang_string_keyword(int $completionstate): string { switch($completionstate) { case COMPLETION_INCOMPLETE: return 'incomplete'; @@ -151,38 +324,69 @@ class condition extends \core_availability\condition { } } - public function get_description($full, $not, \core_availability\info $info) { - // Get name for module. - $modinfo = $info->get_modinfo(); - if (!array_key_exists($this->cmid, $modinfo->cms)) { - $modname = get_string('missing', 'availability_completion'); + /** + * Obtains a string describing this restriction (whether or not + * it actually applies). + * + * @param bool $full Set true if this is the 'full information' view + * @param bool $not Set true if we are inverting the condition + * @param info $info Item we're checking + * @return string Information string (for admin) about all restrictions on + * this item + */ + public function get_description($full, $not, info $info): string { + global $USER; + $str = 'requires_'; + $course = $info->get_course(); + list($selfcmid, $selfsectionid) = $this->get_selfids($info); + $modname = ''; + // On ajax duplicate get_fast_modinfo is called before $PAGE->set_context + // so we cannot use $PAGE->user_is_editing(). + $coursecontext = \context_course::instance($course->id); + $editing = !empty($USER->editing) && has_capability('moodle/course:manageactivities', $coursecontext); + if ($this->cmid == self::OPTION_PREVIOUS && $editing) { + // Previous activity name could be inconsistent when editing due to partial page loadings. + $str .= 'previous_'; } else { - $modname = 'cms[$this->cmid]->id . '/>'; + // Get name for module. + $cmid = $this->get_cmid($course, $selfcmid, $selfsectionid); + $modinfo = $info->get_modinfo(); + if (!array_key_exists($cmid, $modinfo->cms) || $modinfo->cms[$cmid]->deletioninprogress) { + $modname = get_string('missing', 'availability_completion'); + } else { + $modname = 'cms[$cmid]->id . '/>'; + } } - // Work out which lang string to use. + // Work out which lang string to use depending on required completion status. if ($not) { // Convert NOT strings to use the equivalent where possible. switch ($this->expectedcompletion) { case COMPLETION_INCOMPLETE: - $str = 'requires_' . self::get_lang_string_keyword(COMPLETION_COMPLETE); + $str .= self::get_lang_string_keyword(COMPLETION_COMPLETE); break; case COMPLETION_COMPLETE: - $str = 'requires_' . self::get_lang_string_keyword(COMPLETION_INCOMPLETE); + $str .= self::get_lang_string_keyword(COMPLETION_INCOMPLETE); break; default: // The other two cases do not have direct opposites. - $str = 'requires_not_' . self::get_lang_string_keyword($this->expectedcompletion); + $str .= 'not_' . self::get_lang_string_keyword($this->expectedcompletion); break; } } else { - $str = 'requires_' . self::get_lang_string_keyword($this->expectedcompletion); + $str .= self::get_lang_string_keyword($this->expectedcompletion); } return get_string($str, 'availability_completion', $modname); } - protected function get_debug_string() { + /** + * Obtains a representation of the options of this condition as a string, + * for debugging. + * + * @return string Text representation of parameters + */ + protected function get_debug_string(): string { switch ($this->expectedcompletion) { case COMPLETION_COMPLETE : $type = 'COMPLETE'; @@ -199,18 +403,38 @@ class condition extends \core_availability\condition { default: throw new \coding_exception('Unexpected expected completion'); } - return 'cm' . $this->cmid . ' ' . $type; + $cm = $this->cmid; + if ($this->cmid == self::OPTION_PREVIOUS) { + $cm = 'opprevious'; + } + return 'cm' . $cm . ' ' . $type; } - public function update_after_restore($restoreid, $courseid, \base_logger $logger, $name) { + /** + * Updates this node after restore, returning true if anything changed. + * + * @see \core_availability\tree_node\update_after_restore + * + * @param string $restoreid Restore ID + * @param int $courseid ID of target course + * @param \base_logger $logger Logger for any warnings + * @param string $name Name of this item (for use in warning messages) + * @return bool True if there was any change + */ + public function update_after_restore($restoreid, $courseid, \base_logger $logger, $name): bool { global $DB; + $res = false; + // If we depend on the previous activity, no translation is needed. + if ($this->cmid == self::OPTION_PREVIOUS) { + return $res; + } $rec = \restore_dbops::get_backup_ids_record($restoreid, 'course_module', $this->cmid); if (!$rec || !$rec->newitemid) { // If we are on the same course (e.g. duplicate) then we can just // use the existing one. if ($DB->record_exists('course_modules', - array('id' => $this->cmid, 'course' => $courseid))) { - return false; + ['id' => $this->cmid, 'course' => $courseid])) { + return $res; } // Otherwise it's a warning. $this->cmid = 0; @@ -231,13 +455,13 @@ class condition extends \core_availability\condition { * @param int $cmid Course-module id * @return bool True if this is used in a condition, false otherwise */ - public static function completion_value_used($course, $cmid) { + public static function completion_value_used($course, $cmid): bool { // Have we already worked out a list of required completion values // for this course? If so just use that. if (!array_key_exists($course->id, self::$modsusedincondition)) { // We don't have data for this course, build it. $modinfo = get_fast_modinfo($course); - self::$modsusedincondition[$course->id] = array(); + self::$modsusedincondition[$course->id] = []; // Activities. foreach ($modinfo->cms as $othercm) { @@ -247,7 +471,10 @@ class condition extends \core_availability\condition { $ci = new \core_availability\info_module($othercm); $tree = $ci->get_availability_tree(); foreach ($tree->get_all_children('availability_completion\condition') as $cond) { - self::$modsusedincondition[$course->id][$cond->cmid] = true; + $condcmid = $cond->get_cmid($course, $othercm->id, null); + if (!empty($condcmid)) { + self::$modsusedincondition[$course->id][$condcmid] = true; + } } } @@ -259,7 +486,10 @@ class condition extends \core_availability\condition { $ci = new \core_availability\info_section($section); $tree = $ci->get_availability_tree(); foreach ($tree->get_all_children('availability_completion\condition') as $cond) { - self::$modsusedincondition[$course->id][$cond->cmid] = true; + $condcmid = $cond->get_cmid($course, null, $section->id); + if (!empty($condcmid)) { + self::$modsusedincondition[$course->id][$condcmid] = true; + } } } } @@ -270,7 +500,7 @@ class condition extends \core_availability\condition { * Wipes the static cache of modules used in a condition (for unit testing). */ public static function wipe_static_cache() { - self::$modsusedincondition = array(); + self::$modsusedincondition = []; } public function update_dependency_id($table, $oldid, $newid) { diff --git a/availability/condition/completion/classes/frontend.php b/availability/condition/completion/classes/frontend.php index 7427328c4dc..c6466854874 100644 --- a/availability/condition/completion/classes/frontend.php +++ b/availability/condition/completion/classes/frontend.php @@ -37,7 +37,7 @@ class frontend extends \core_availability\frontend { /** * @var array Cached init parameters */ - protected $cacheparams = array(); + protected $cacheparams = []; /** * @var string IDs of course, cm, and section for cache (if any) @@ -45,8 +45,8 @@ class frontend extends \core_availability\frontend { protected $cachekey = ''; protected function get_javascript_strings() { - return array('option_complete', 'option_fail', 'option_incomplete', 'option_pass', - 'label_cm', 'label_completion'); + return ['option_complete', 'option_fail', 'option_incomplete', 'option_pass', + 'label_cm', 'label_completion']; } protected function get_javascript_init_params($course, \cm_info $cm = null, @@ -59,20 +59,29 @@ class frontend extends \core_availability\frontend { // Get list of activities on course which have completion values, // to fill the dropdown. $context = \context_course::instance($course->id); - $cms = array(); + $cms = []; $modinfo = get_fast_modinfo($course); + $previouscm = false; foreach ($modinfo->cms as $id => $othercm) { // Add each course-module if it has completion turned on and is not // the one currently being edited. if ($othercm->completion && (empty($cm) || $cm->id != $id) && !$othercm->deletioninprogress) { - $cms[] = (object)array('id' => $id, - 'name' => format_string($othercm->name, true, array('context' => $context)), - 'completiongradeitemnumber' => $othercm->completiongradeitemnumber); + $cms[] = (object)['id' => $id, + 'name' => format_string($othercm->name, true, ['context' => $context]), + 'completiongradeitemnumber' => $othercm->completiongradeitemnumber]; + } + if (count($cms) && (empty($cm) || $cm->id == $id)) { + $previouscm = true; } } - + if ($previouscm) { + $previous = (object)['id' => \availability_completion\condition::OPTION_PREVIOUS, + 'name' => get_string('option_previous', 'availability_completion'), + 'completiongradeitemnumber' => \availability_completion\condition::OPTION_PREVIOUS]; + array_unshift($cms, $previous); + } $this->cachekey = $cachekey; - $this->cacheinitparams = array($cms); + $this->cacheinitparams = [$cms]; } return $this->cacheinitparams; } diff --git a/availability/condition/completion/db/caches.php b/availability/condition/completion/db/caches.php new file mode 100644 index 00000000000..e365533657d --- /dev/null +++ b/availability/condition/completion/db/caches.php @@ -0,0 +1,35 @@ +. + +/** + * Defined caches used internally by the plugin. + * + * @package availability_completion + * @category cache + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$definitions = [ + 'previous_cache' => [ + 'mode' => cache_store::MODE_REQUEST, + 'simplekeys' => true, + 'simpledata' => true, + 'staticacceleration' => true + ], +]; diff --git a/availability/condition/completion/lang/en/availability_completion.php b/availability/condition/completion/lang/en/availability_completion.php index f3a8693cf3a..4d406daca35 100644 --- a/availability/condition/completion/lang/en/availability_completion.php +++ b/availability/condition/completion/lang/en/availability_completion.php @@ -22,6 +22,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +$string['cachedef_previous_cache'] = 'Previous activity dependency information'; $string['description'] = 'Require students to complete (or not complete) another activity.'; $string['error_selectcmid'] = 'You must select an activity for the completion condition.'; $string['error_selectcmidpassfail'] = 'You must select an activity with "Require grade" completion condition set.'; @@ -32,6 +33,7 @@ $string['option_complete'] = 'must be marked complete'; $string['option_fail'] = 'must be complete with fail grade'; $string['option_incomplete'] = 'must not be marked complete'; $string['option_pass'] = 'must be complete with pass grade'; +$string['option_previous'] = 'Previous activity with completion'; $string['pluginname'] = 'Restriction by activity completion'; $string['requires_incomplete'] = 'The activity {$a} is incomplete'; $string['requires_complete'] = 'The activity {$a} is marked complete'; @@ -39,5 +41,11 @@ $string['requires_complete_pass'] = 'The activity {$a} is compl $string['requires_complete_fail'] = 'The activity {$a} is complete and failed'; $string['requires_not_complete_pass'] = 'The activity {$a} is not complete and passed'; $string['requires_not_complete_fail'] = 'The activity {$a} is not complete and failed'; +$string['requires_previous_incomplete'] = 'The previous activity with completion is incomplete'; +$string['requires_previous_complete'] = 'The previous activity with completion is marked complete'; +$string['requires_previous_complete_pass'] = 'The previous activity with completion is complete and passed'; +$string['requires_previous_complete_fail'] = 'The previous activity with completion is complete and failed'; +$string['requires_previous_not_complete_pass'] = 'The previous activity with completion is not complete and passed'; +$string['requires_previous_not_complete_fail'] = 'The previous activity with completion is not complete and failed'; $string['title'] = 'Activity completion'; $string['privacy:metadata'] = 'The Restriction by activity completion plugin does not store any personal data.'; diff --git a/availability/condition/completion/tests/behat/availability_completion_previous.feature b/availability/condition/completion/tests/behat/availability_completion_previous.feature new file mode 100644 index 00000000000..ae21266a7c3 --- /dev/null +++ b/availability/condition/completion/tests/behat/availability_completion_previous.feature @@ -0,0 +1,200 @@ +@availability @availability_completion +Feature: Confirm that availability_completion works with previous activity setting + In order to control student access to activities + As a teacher + I need to set completion conditions which prevent student access + + Background: + Given the following "courses" exist: + | fullname | shortname | format | enablecompletion | numsections | + | Course 1 | C1 | topics | 1 | 5 | + And the following "users" exist: + | username | + | teacher1 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + Given the following "activities" exist: + | activity | name | intro | course | idnumber | groupmode | completion | section | + | page | Page1 | Page 1 description | C1 | page1 | 1 | 1 | 1 | + | page | Page Ignored 1 | Page Ignored | C1 | pagei1 | 1 | 0 | 1 | + | page | Page2 | Page 2 description | C1 | page2 | 1 | 1 | 3 | + | page | Page3 | Page 3 description | C1 | page3 | 1 | 1 | 4 | + + @javascript + Scenario: Test condition with previous activity on an activity + Given I log in as "teacher1" + And I am on "Course 1" course homepage with editing mode on + + # Set Page3 restriction to Previous Activity with completion. + When I open "Page3" actions menu + And I click on "Edit settings" "link" in the "Page3" activity + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Activity completion" "button" in the "Add restriction..." "dialogue" + And I click on "Displayed greyed-out if user does not meet this condition • Click to hide" "link" + And I set the field "Activity or resource" to "Previous activity with completion" + And I press "Save and return to course" + Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region" + + When I turn editing mode off + Then I should see "Not available unless: The activity Page2 is marked complete" in the "region-main" "region" + + # Remove Page 2 and check Page3 depends now on Page1. + When I turn editing mode on + And I delete "Page2" activity + And I turn editing mode off + Then I should see "Not available unless: The activity Page1 is marked complete" in the "region-main" "region" + + @javascript + Scenario: Test previous activity availability when duplicate an activity + Given I log in as "teacher1" + And I am on "Course 1" course homepage with editing mode on + + # Set Page3 restriction to Previous Activity with completion. + When I open "Page3" actions menu + And I click on "Edit settings" "link" in the "Page3" activity + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Activity completion" "button" in the "Add restriction..." "dialogue" + And I click on "Displayed greyed-out if user does not meet this condition • Click to hide" "link" + And I set the field "Activity or resource" to "Previous activity with completion" + And I press "Save and return to course" + Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region" + + When I turn editing mode off + Then I should see "Not available unless: The activity Page2 is marked complete" in the "region-main" "region" + + # Duplicate Page3. + When I turn editing mode on + And I duplicate "Page3" activity + And I turn editing mode off + Then I should see "Not available unless: The activity Page3 is marked complete" in the "region-main" "region" + + @javascript + Scenario: Test previous activity availability when modify completion tacking + Given I log in as "teacher1" + And I am on "Course 1" course homepage with editing mode on + + # Set Page3 restriction to Previous Activity with completion. + When I open "Page3" actions menu + And I click on "Edit settings" "link" in the "Page3" activity + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Activity completion" "button" in the "Add restriction..." "dialogue" + And I click on "Displayed greyed-out if user does not meet this condition • Click to hide" "link" + And I set the field "Activity or resource" to "Previous activity with completion" + And I press "Save and return to course" + Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region" + + When I turn editing mode off + Then I should see "Not available unless: The activity Page2 is marked complete" in the "region-main" "region" + + # Test if I disable completion tracking on Page2 section 5 depends on Page2. + When I turn editing mode on + When I open "Page2" actions menu + And I click on "Edit settings" "link" in the "Page2" activity + And I set the following fields to these values: + | Completion tracking | Do not indicate activity completion | + And I press "Save and return to course" + When I turn editing mode off + Then I should see "Not available unless: The activity Page1 is marked complete" in the "region-main" "region" + + @javascript + Scenario: Test condition with previous activity on a section + Given I log in as "teacher1" + And I am on "Course 1" course homepage with editing mode on + + # Set section 4 restriction to Previous Activity with completion. + When I edit the section "4" + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Activity completion" "button" in the "Add restriction..." "dialogue" + And I click on "Displayed greyed-out if user does not meet this condition • Click to hide" "link" + And I set the field "Activity or resource" to "Previous activity with completion" + And I press "Save changes" + Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region" + + When I turn editing mode off + Then I should see "Not available unless: The activity Page2 is marked complete" in the "region-main" "region" + + # Remove Page 2 and check Section 4 depends now on Page1. + When I turn editing mode on + And I delete "Page2" activity + And I turn editing mode off + Then I should see "Not available unless: The activity Page1 is marked complete" in the "region-main" "region" + + @javascript + Scenario: Test condition with previous activity on the first activity of the course + Given I log in as "teacher1" + And I am on "Course 1" course homepage with editing mode on + + # Try to set Page1 restriction to Previous Activity with completion. + When I open "Page1" actions menu + And I click on "Edit settings" "link" in the "Page1" activity + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Activity completion" "button" in the "Add restriction..." "dialogue" + And I click on "Displayed greyed-out if user does not meet this condition • Click to hide" "link" + Then the "Activity or resource" select box should not contain "Previous activity with completion" + + # Set Page2 restriction to Previous Activity with completion and delete Page1. + When I am on "Course 1" course homepage + When I open "Page2" actions menu + And I click on "Edit settings" "link" in the "Page2" activity + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Activity completion" "button" in the "Add restriction..." "dialogue" + And I click on "Displayed greyed-out if user does not meet this condition • Click to hide" "link" + And I set the field "Activity or resource" to "Previous activity with completion" + And I press "Save and return to course" + Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region" + + # Delete Page 1 and check than Page2 now depends on a missing activity (no previous activity found). + When I am on "Course 1" course homepage + And I delete "Page1" activity + And I turn editing mode off + Then I should see "Not available unless: The activity (Missing activity)" in the "region-main" "region" + + @javascript + Scenario: Test previous activities on empty sections + Given I log in as "teacher1" + And I am on "Course 1" course homepage with editing mode on + + # Set section 2 restriction to Previous Activity with completion. + When I edit the section "2" + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Activity completion" "button" in the "Add restriction..." "dialogue" + And I click on "Displayed greyed-out if user does not meet this condition • Click to hide" "link" + And I set the field "Activity or resource" to "Previous activity with completion" + And I press "Save changes" + Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region" + + When I turn editing mode off + Then I should see "Not available unless: The activity Page1 is marked complete" in the "region-main" "region" + + # Set section 5 restriction to Previous Activity with completion. + When I turn editing mode on + And I edit the section "5" + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Activity completion" "button" in the "Add restriction..." "dialogue" + And I click on "Displayed greyed-out if user does not meet this condition • Click to hide" "link" + And I set the field "Activity or resource" to "Previous activity with completion" + And I press "Save changes" + Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region" + + When I turn editing mode off + Then I should see "Not available unless: The activity Page3 is marked complete" in the "region-main" "region" + + # Test if I disable completion tracking on Page3 section 5 depends on Page2. + When I turn editing mode on + And I open "Page3" actions menu + And I click on "Edit settings" "link" in the "Page3" activity + And I set the following fields to these values: + | Completion tracking | Do not indicate activity completion | + And I press "Save and return to course" + + When I turn editing mode off + Then I should see "Not available unless: The activity Page2 is marked complete" in the "region-main" "region" diff --git a/availability/condition/completion/tests/condition_test.php b/availability/condition/completion/tests/condition_test.php index c64be08752c..b1a004093c6 100644 --- a/availability/condition/completion/tests/condition_test.php +++ b/availability/condition/completion/tests/condition_test.php @@ -37,13 +37,23 @@ require_once($CFG->libdir . '/completionlib.php'); * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class availability_completion_condition_testcase extends advanced_testcase { + + /** + * Setup to ensure that fixtures are loaded. + */ + public static function setupBeforeClass(): void { + global $CFG; + // Load the mock info class so that it can be used. + require_once($CFG->dirroot . '/availability/tests/fixtures/mock_info.php'); + require_once($CFG->dirroot . '/availability/tests/fixtures/mock_info_module.php'); + require_once($CFG->dirroot . '/availability/tests/fixtures/mock_info_section.php'); + } + /** * Load required classes. */ public function setUp() { - // Load the mock info class so that it can be used. - global $CFG; - require_once($CFG->dirroot . '/availability/tests/fixtures/mock_info.php'); + availability_completion\condition::wipe_static_cache(); } /** @@ -59,17 +69,27 @@ class availability_completion_condition_testcase extends advanced_testcase { $CFG->enablecompletion = true; $CFG->enableavailability = true; $generator = $this->getDataGenerator(); - $course = $generator->create_course(array('enablecompletion' => 1)); + $course = $generator->create_course(['enablecompletion' => 1]); $page = $generator->get_plugin_generator('mod_page')->create_instance( - array('course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL)); + ['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]); + $selfpage = $generator->get_plugin_generator('mod_page')->create_instance( + ['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]); $modinfo = get_fast_modinfo($course); $cm = $modinfo->get_cm($page->cmid); $info = new \core_availability\mock_info($course, $USER->id); - $structure = (object)array('op' => '|', 'show' => true, 'c' => array( - (object)array('type' => 'completion', 'cm' => (int)$cm->id, - 'e' => COMPLETION_COMPLETE))); + $structure = (object)[ + 'op' => '|', + 'show' => true, + 'c' => [ + (object)[ + 'type' => 'completion', + 'cm' => (int)$cm->id, + 'e' => COMPLETION_COMPLETE + ] + ] + ]; $tree = new \core_availability\tree($structure); // Initial check (user has not completed activity). @@ -142,13 +162,19 @@ class availability_completion_condition_testcase extends advanced_testcase { $structure->e = COMPLETION_INCOMPLETE; $cond = new condition($structure); $this->assertEquals('{completion:cm42 INCOMPLETE}', (string)$cond); + + // Successful contruct with previous activity. + $structure->cm = condition::OPTION_PREVIOUS; + $cond = new condition($structure); + $this->assertEquals('{completion:cmopprevious INCOMPLETE}', (string)$cond); + } /** * Tests the save() function. */ public function test_save() { - $structure = (object)array('cm' => 42, 'e' => COMPLETION_COMPLETE); + $structure = (object)['cm' => 42, 'e' => COMPLETION_COMPLETE]; $cond = new condition($structure); $structure->type = 'completion'; $this->assertEquals($structure, $cond->save()); @@ -166,24 +192,24 @@ class availability_completion_condition_testcase extends advanced_testcase { $CFG->enablecompletion = true; $CFG->enableavailability = true; $generator = $this->getDataGenerator(); - $course = $generator->create_course(array('enablecompletion' => 1)); + $course = $generator->create_course(['enablecompletion' => 1]); $user = $generator->create_user(); $generator->enrol_user($user->id, $course->id); $this->setUser($user); // Create a Page with manual completion for basic checks. $page = $generator->get_plugin_generator('mod_page')->create_instance( - array('course' => $course->id, 'name' => 'Page!', - 'completion' => COMPLETION_TRACKING_MANUAL)); + ['course' => $course->id, 'name' => 'Page!', + 'completion' => COMPLETION_TRACKING_MANUAL]); // Create an assignment - we need to have something that can be graded // so as to test the PASS/FAIL states. Set it up to be completed based // on its grade item. - $assignrow = $this->getDataGenerator()->create_module('assign', array( - 'course' => $course->id, 'name' => 'Assign!', - 'completion' => COMPLETION_TRACKING_AUTOMATIC)); + $assignrow = $this->getDataGenerator()->create_module('assign', [ + 'course' => $course->id, 'name' => 'Assign!', + 'completion' => COMPLETION_TRACKING_AUTOMATIC]); $DB->set_field('course_modules', 'completiongradeitemnumber', 0, - array('id' => $assignrow->cmid)); + ['id' => $assignrow->cmid]); $assign = new assign(context_module::instance($assignrow->cmid), false, false); // Get basic details. @@ -193,8 +219,9 @@ class availability_completion_condition_testcase extends advanced_testcase { $info = new \core_availability\mock_info($course, $user->id); // COMPLETE state (false), positive and NOT. - $cond = new condition((object)array( - 'cm' => (int)$pagecm->id, 'e' => COMPLETION_COMPLETE)); + $cond = new condition((object)[ + 'cm' => (int)$pagecm->id, 'e' => COMPLETION_COMPLETE + ]); $this->assertFalse($cond->is_available(false, $info, true, $user->id)); $information = $cond->get_description(false, false, $info); $information = \core_availability\info::format_info($information, $course); @@ -202,8 +229,9 @@ class availability_completion_condition_testcase extends advanced_testcase { $this->assertTrue($cond->is_available(true, $info, true, $user->id)); // INCOMPLETE state (true). - $cond = new condition((object)array( - 'cm' => (int)$pagecm->id, 'e' => COMPLETION_INCOMPLETE)); + $cond = new condition((object)[ + 'cm' => (int)$pagecm->id, 'e' => COMPLETION_INCOMPLETE + ]); $this->assertTrue($cond->is_available(false, $info, true, $user->id)); $this->assertFalse($cond->is_available(true, $info, true, $user->id)); $information = $cond->get_description(false, true, $info); @@ -215,8 +243,9 @@ class availability_completion_condition_testcase extends advanced_testcase { $completion->update_state($pagecm, COMPLETION_COMPLETE); // COMPLETE state (true). - $cond = new condition((object)array( - 'cm' => (int)$pagecm->id, 'e' => COMPLETION_COMPLETE)); + $cond = new condition((object)[ + 'cm' => (int)$pagecm->id, 'e' => COMPLETION_COMPLETE + ]); $this->assertTrue($cond->is_available(false, $info, true, $user->id)); $this->assertFalse($cond->is_available(true, $info, true, $user->id)); $information = $cond->get_description(false, true, $info); @@ -224,8 +253,9 @@ class availability_completion_condition_testcase extends advanced_testcase { $this->assertRegExp('~Page!.*is incomplete~', $information); // INCOMPLETE state (false). - $cond = new condition((object)array( - 'cm' => (int)$pagecm->id, 'e' => COMPLETION_INCOMPLETE)); + $cond = new condition((object)[ + 'cm' => (int)$pagecm->id, 'e' => COMPLETION_INCOMPLETE + ]); $this->assertFalse($cond->is_available(false, $info, true, $user->id)); $information = $cond->get_description(false, false, $info); $information = \core_availability\info::format_info($information, $course); @@ -235,32 +265,36 @@ class availability_completion_condition_testcase extends advanced_testcase { // We are going to need the grade item so that we can get pass/fails. $gradeitem = $assign->get_grade_item(); - grade_object::set_properties($gradeitem, array('gradepass' => 50.0)); + grade_object::set_properties($gradeitem, ['gradepass' => 50.0]); $gradeitem->update(); // With no grade, it should return true for INCOMPLETE and false for // the other three. - $cond = new condition((object)array( - 'cm' => (int)$assigncm->id, 'e' => COMPLETION_INCOMPLETE)); + $cond = new condition((object)[ + 'cm' => (int)$assigncm->id, 'e' => COMPLETION_INCOMPLETE + ]); $this->assertTrue($cond->is_available(false, $info, true, $user->id)); $this->assertFalse($cond->is_available(true, $info, true, $user->id)); - $cond = new condition((object)array( - 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE)); + $cond = new condition((object)[ + 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE + ]); $this->assertFalse($cond->is_available(false, $info, true, $user->id)); $this->assertTrue($cond->is_available(true, $info, true, $user->id)); // Check $information for COMPLETE_PASS and _FAIL as we haven't yet. - $cond = new condition((object)array( - 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_PASS)); + $cond = new condition((object)[ + 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_PASS + ]); $this->assertFalse($cond->is_available(false, $info, true, $user->id)); $information = $cond->get_description(false, false, $info); $information = \core_availability\info::format_info($information, $course); $this->assertRegExp('~Assign!.*is complete and passed~', $information); $this->assertTrue($cond->is_available(true, $info, true, $user->id)); - $cond = new condition((object)array( - 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_FAIL)); + $cond = new condition((object)[ + 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_FAIL + ]); $this->assertFalse($cond->is_available(false, $info, true, $user->id)); $information = $cond->get_description(false, false, $info); $information = \core_availability\info::format_info($information, $course); @@ -270,26 +304,30 @@ class availability_completion_condition_testcase extends advanced_testcase { // Change the grade to be complete and failed. self::set_grade($assignrow, $user->id, 40); - $cond = new condition((object)array( - 'cm' => (int)$assigncm->id, 'e' => COMPLETION_INCOMPLETE)); + $cond = new condition((object)[ + 'cm' => (int)$assigncm->id, 'e' => COMPLETION_INCOMPLETE + ]); $this->assertFalse($cond->is_available(false, $info, true, $user->id)); $this->assertTrue($cond->is_available(true, $info, true, $user->id)); - $cond = new condition((object)array( - 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE)); + $cond = new condition((object)[ + 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE + ]); $this->assertTrue($cond->is_available(false, $info, true, $user->id)); $this->assertFalse($cond->is_available(true, $info, true, $user->id)); - $cond = new condition((object)array( - 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_PASS)); + $cond = new condition((object)[ + 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_PASS + ]); $this->assertFalse($cond->is_available(false, $info, true, $user->id)); $information = $cond->get_description(false, false, $info); $information = \core_availability\info::format_info($information, $course); $this->assertRegExp('~Assign!.*is complete and passed~', $information); $this->assertTrue($cond->is_available(true, $info, true, $user->id)); - $cond = new condition((object)array( - 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_FAIL)); + $cond = new condition((object)[ + 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_FAIL + ]); $this->assertTrue($cond->is_available(false, $info, true, $user->id)); $this->assertFalse($cond->is_available(true, $info, true, $user->id)); $information = $cond->get_description(false, true, $info); @@ -299,26 +337,30 @@ class availability_completion_condition_testcase extends advanced_testcase { // Now change it to pass. self::set_grade($assignrow, $user->id, 60); - $cond = new condition((object)array( - 'cm' => (int)$assigncm->id, 'e' => COMPLETION_INCOMPLETE)); + $cond = new condition((object)[ + 'cm' => (int)$assigncm->id, 'e' => COMPLETION_INCOMPLETE + ]); $this->assertFalse($cond->is_available(false, $info, true, $user->id)); $this->assertTrue($cond->is_available(true, $info, true, $user->id)); - $cond = new condition((object)array( - 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE)); + $cond = new condition((object)[ + 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE + ]); $this->assertTrue($cond->is_available(false, $info, true, $user->id)); $this->assertFalse($cond->is_available(true, $info, true, $user->id)); - $cond = new condition((object)array( - 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_PASS)); + $cond = new condition((object)[ + 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_PASS + ]); $this->assertTrue($cond->is_available(false, $info, true, $user->id)); $this->assertFalse($cond->is_available(true, $info, true, $user->id)); $information = $cond->get_description(false, true, $info); $information = \core_availability\info::format_info($information, $course); $this->assertRegExp('~Assign!.*is not complete and passed~', $information); - $cond = new condition((object)array( - 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_FAIL)); + $cond = new condition((object)[ + 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_FAIL + ]); $this->assertFalse($cond->is_available(false, $info, true, $user->id)); $information = $cond->get_description(false, false, $info); $information = \core_availability\info::format_info($information, $course); @@ -327,59 +369,390 @@ class availability_completion_condition_testcase extends advanced_testcase { // Simulate deletion of an activity by using an invalid cmid. These // conditions always fail, regardless of NOT flag or INCOMPLETE. - $cond = new condition((object)array( - 'cm' => ($assigncm->id + 100), 'e' => COMPLETION_COMPLETE)); + $cond = new condition((object)[ + 'cm' => ($assigncm->id + 100), 'e' => COMPLETION_COMPLETE + ]); $this->assertFalse($cond->is_available(false, $info, true, $user->id)); $information = $cond->get_description(false, false, $info); $information = \core_availability\info::format_info($information, $course); $this->assertRegExp('~(Missing activity).*is marked complete~', $information); $this->assertFalse($cond->is_available(true, $info, true, $user->id)); - $cond = new condition((object)array( - 'cm' => ($assigncm->id + 100), 'e' => COMPLETION_INCOMPLETE)); + $cond = new condition((object)[ + 'cm' => ($assigncm->id + 100), 'e' => COMPLETION_INCOMPLETE + ]); $this->assertFalse($cond->is_available(false, $info, true, $user->id)); } + /** + * Tests the is_available and get_description functions for previous activity option. + * + * @dataProvider test_previous_activity_data + * @param int $grade the current assign grade (0 for none) + * @param int $condition true for complete, false for incomplete + * @param string $mark activity to mark as complete + * @param string $activity activity name to test + * @param bool $result if it must be available or not + * @param bool $resultnot if it must be available when the condition is inverted + * @param string $description the availabiklity text to check + */ + public function test_previous_activity(int $grade, int $condition, string $mark, string $activity, + bool $result, bool $resultnot, string $description): void { + global $CFG, $DB; + require_once($CFG->dirroot . '/mod/assign/locallib.php'); + $this->resetAfterTest(); + + // Create course with completion turned on. + $CFG->enablecompletion = true; + $CFG->enableavailability = true; + $generator = $this->getDataGenerator(); + $course = $generator->create_course(['enablecompletion' => 1]); + $user = $generator->create_user(); + $generator->enrol_user($user->id, $course->id); + $this->setUser($user); + + // Page 1 (manual completion). + $page1 = $generator->get_plugin_generator('mod_page')->create_instance( + ['course' => $course->id, 'name' => 'Page1!', + 'completion' => COMPLETION_TRACKING_MANUAL]); + + // Page 2 (manual completion). + $page2 = $generator->get_plugin_generator('mod_page')->create_instance( + ['course' => $course->id, 'name' => 'Page2!', + 'completion' => COMPLETION_TRACKING_MANUAL]); + + // Page ignored (no completion). + $pagenocompletion = $generator->get_plugin_generator('mod_page')->create_instance( + ['course' => $course->id, 'name' => 'Page ignored!']); + + // Create an assignment - we need to have something that can be graded + // so as to test the PASS/FAIL states. Set it up to be completed based + // on its grade item. + $assignrow = $this->getDataGenerator()->create_module('assign', [ + 'course' => $course->id, 'name' => 'Assign!', + 'completion' => COMPLETION_TRACKING_AUTOMATIC + ]); + $DB->set_field('course_modules', 'completiongradeitemnumber', 0, + ['id' => $assignrow->cmid]); + $assign = new assign(context_module::instance($assignrow->cmid), false, false); + + // Page 3 (manual completion). + $page3 = $generator->get_plugin_generator('mod_page')->create_instance( + ['course' => $course->id, 'name' => 'Page3!', + 'completion' => COMPLETION_TRACKING_MANUAL]); + + // Get basic details. + $activities = []; + $modinfo = get_fast_modinfo($course); + $activities['page1'] = $modinfo->get_cm($page1->cmid); + $activities['page2'] = $modinfo->get_cm($page2->cmid); + $activities['assign'] = $assign->get_course_module(); + $activities['page3'] = $modinfo->get_cm($page3->cmid); + $prevvalue = condition::OPTION_PREVIOUS; + + // Setup gradings and completion. + if ($grade) { + $gradeitem = $assign->get_grade_item(); + grade_object::set_properties($gradeitem, ['gradepass' => 50.0]); + $gradeitem->update(); + self::set_grade($assignrow, $user->id, $grade); + } + if ($mark) { + $completion = new completion_info($course); + $completion->update_state($activities[$mark], COMPLETION_COMPLETE); + } + + // Set opprevious WITH non existent previous activity. + $info = new \core_availability\mock_info_module($user->id, $activities[$activity]); + $cond = new condition((object)[ + 'cm' => (int)$prevvalue, 'e' => $condition + ]); + + // Do the checks. + $this->assertEquals($result, $cond->is_available(false, $info, true, $user->id)); + $this->assertEquals($resultnot, $cond->is_available(true, $info, true, $user->id)); + $information = $cond->get_description(false, false, $info); + $information = \core_availability\info::format_info($information, $course); + $this->assertRegExp($description, $information); + } + + public function test_previous_activity_data(): array { + // Assign grade, condition, activity to complete, activity to test, result, resultnot, description. + return [ + 'Missing previous activity complete' => [ + 0, COMPLETION_COMPLETE, '', 'page1', false, false, '~Missing activity.*is marked complete~' + ], + 'Missing previous activity incomplete' => [ + 0, COMPLETION_INCOMPLETE, '', 'page1', false, false, '~Missing activity.*is incomplete~' + ], + 'Previous complete condition with previous activity incompleted' => [ + 0, COMPLETION_COMPLETE, '', 'page2', false, true, '~Page1!.*is marked complete~' + ], + 'Previous incomplete condition with previous activity incompleted' => [ + 0, COMPLETION_INCOMPLETE, '', 'page2', true, false, '~Page1!.*is incomplete~' + ], + 'Previous complete condition with previous activity completed' => [ + 0, COMPLETION_COMPLETE, 'page1', 'page2', true, false, '~Page1!.*is marked complete~' + ], + 'Previous incomplete condition with previous activity completed' => [ + 0, COMPLETION_INCOMPLETE, 'page1', 'page2', false, true, '~Page1!.*is incomplete~' + ], + // Depenging on page pass fail (pages are not gradable). + 'Previous complete pass condition with previous no gradable activity incompleted' => [ + 0, COMPLETION_COMPLETE_PASS, '', 'page2', false, true, '~Page1!.*is complete and passed~' + ], + 'Previous complete fail condition with previous no gradable activity incompleted' => [ + 0, COMPLETION_COMPLETE_FAIL, '', 'page2', false, true, '~Page1!.*is complete and failed~' + ], + 'Previous complete pass condition with previous no gradable activity completed' => [ + 0, COMPLETION_COMPLETE_PASS, 'page1', 'page2', false, true, '~Page1!.*is complete and passed~' + ], + 'Previous complete fail condition with previous no gradable activity completed' => [ + 0, COMPLETION_COMPLETE_FAIL, 'page1', 'page2', false, true, '~Page1!.*is complete and failed~' + ], + // There's an page without completion between page2 ans assign. + 'Previous complete condition with sibling activity incompleted' => [ + 0, COMPLETION_COMPLETE, '', 'assign', false, true, '~Page2!.*is marked complete~' + ], + 'Previous incomplete condition with sibling activity incompleted' => [ + 0, COMPLETION_INCOMPLETE, '', 'assign', true, false, '~Page2!.*is incomplete~' + ], + 'Previous complete condition with sibling activity completed' => [ + 0, COMPLETION_COMPLETE, 'page2', 'assign', true, false, '~Page2!.*is marked complete~' + ], + 'Previous incomplete condition with sibling activity completed' => [ + 0, COMPLETION_INCOMPLETE, 'page2', 'assign', false, true, '~Page2!.*is incomplete~' + ], + // Depending on assign without grade. + 'Previous complete condition with previous without grade' => [ + 0, COMPLETION_COMPLETE, '', 'page3', false, true, '~Assign!.*is marked complete~' + ], + 'Previous incomplete condition with previous without grade' => [ + 0, COMPLETION_INCOMPLETE, '', 'page3', true, false, '~Assign!.*is incomplete~' + ], + 'Previous complete pass condition with previous without grade' => [ + 0, COMPLETION_COMPLETE_PASS, '', 'page3', false, true, '~Assign!.*is complete and passed~' + ], + 'Previous complete fail condition with previous without grade' => [ + 0, COMPLETION_COMPLETE_FAIL, '', 'page3', false, true, '~Assign!.*is complete and failed~' + ], + // Depending on assign with grade. + 'Previous complete condition with previous fail grade' => [ + 40, COMPLETION_COMPLETE, '', 'page3', true, false, '~Assign!.*is marked complete~' + ], + 'Previous incomplete condition with previous fail grade' => [ + 40, COMPLETION_INCOMPLETE, '', 'page3', false, true, '~Assign!.*is incomplete~' + ], + 'Previous complete pass condition with previous fail grade' => [ + 40, COMPLETION_COMPLETE_PASS, '', 'page3', false, true, '~Assign!.*is complete and passed~' + ], + 'Previous complete fail condition with previous fail grade' => [ + 40, COMPLETION_COMPLETE_FAIL, '', 'page3', true, false, '~Assign!.*is complete and failed~' + ], + 'Previous complete condition with previous pass grade' => [ + 60, COMPLETION_COMPLETE, '', 'page3', true, false, '~Assign!.*is marked complete~' + ], + 'Previous incomplete condition with previous pass grade' => [ + 60, COMPLETION_INCOMPLETE, '', 'page3', false, true, '~Assign!.*is incomplete~' + ], + 'Previous complete pass condition with previous pass grade' => [ + 60, COMPLETION_COMPLETE_PASS, '', 'page3', true, false, '~Assign!.*is complete and passed~' + ], + 'Previous complete fail condition with previous pass grade' => [ + 60, COMPLETION_COMPLETE_FAIL, '', 'page3', false, true, '~Assign!.*is complete and failed~' + ], + ]; + } + + /** + * Tests the is_available and get_description functions for + * previous activity option in course sections. + * + * @dataProvider test_section_previous_activity_data + * @param int $condition condition value + * @param bool $mark if Page 1 must be mark as completed + * @param string $section section to add the availability + * @param bool $result expected result + * @param bool $resultnot expected negated result + * @param string $description description to match + */ + public function test_section_previous_activity(int $condition, bool $mark, string $section, + bool $result, bool $resultnot, string $description): void { + global $CFG, $DB; + require_once($CFG->dirroot . '/mod/assign/locallib.php'); + $this->resetAfterTest(); + + // Create course with completion turned on. + $CFG->enablecompletion = true; + $CFG->enableavailability = true; + $generator = $this->getDataGenerator(); + $course = $generator->create_course( + ['numsections' => 4, 'enablecompletion' => 1], + ['createsections' => true]); + $user = $generator->create_user(); + $generator->enrol_user($user->id, $course->id); + $this->setUser($user); + + // Section 1 - page1 (manual completion). + $page1 = $generator->get_plugin_generator('mod_page')->create_instance( + ['course' => $course->id, 'name' => 'Page1!', 'section' => 1, + 'completion' => COMPLETION_TRACKING_MANUAL]); + + // Section 1 - page ignored 1 (no completion). + $pagenocompletion1 = $generator->get_plugin_generator('mod_page')->create_instance( + ['course' => $course, 'name' => 'Page ignored!', 'section' => 1]); + + // Section 2 - page ignored 2 (no completion). + $pagenocompletion2 = $generator->get_plugin_generator('mod_page')->create_instance( + ['course' => $course, 'name' => 'Page ignored!', 'section' => 2]); + + // Section 3 - page2 (manual completion). + $page2 = $generator->get_plugin_generator('mod_page')->create_instance( + ['course' => $course->id, 'name' => 'Page2!', 'section' => 3, + 'completion' => COMPLETION_TRACKING_MANUAL]); + + // Section 4 is empty. + + // Get basic details. + get_fast_modinfo(0, 0, true); + $modinfo = get_fast_modinfo($course); + $sections['section1'] = $modinfo->get_section_info(1); + $sections['section2'] = $modinfo->get_section_info(2); + $sections['section3'] = $modinfo->get_section_info(3); + $sections['section4'] = $modinfo->get_section_info(4); + $page1cm = $modinfo->get_cm($page1->cmid); + $prevvalue = condition::OPTION_PREVIOUS; + + if ($mark) { + // Mark page1 complete. + $completion = new completion_info($course); + $completion->update_state($page1cm, COMPLETION_COMPLETE); + } + + $info = new \core_availability\mock_info_section($user->id, $sections[$section]); + $cond = new condition((object)[ + 'cm' => (int)$prevvalue, 'e' => $condition + ]); + $this->assertEquals($result, $cond->is_available(false, $info, true, $user->id)); + $this->assertEquals($resultnot, $cond->is_available(true, $info, true, $user->id)); + $information = $cond->get_description(false, false, $info); + $information = \core_availability\info::format_info($information, $course); + $this->assertRegExp($description, $information); + + } + + public function test_section_previous_activity_data(): array { + return [ + // Condition, Activity completion, section to test, result, resultnot, description. + 'Completion complete Section with no previous activity' => [ + COMPLETION_COMPLETE, false, 'section1', false, false, '~Missing activity.*is marked complete~' + ], + 'Completion incomplete Section with no previous activity' => [ + COMPLETION_INCOMPLETE, false, 'section1', false, false, '~Missing activity.*is incomplete~' + ], + // Section 2 depending on section 1 -> Page 1 (no grading). + 'Completion complete Section with previous activity incompleted' => [ + COMPLETION_COMPLETE, false, 'section2', false, true, '~Page1!.*is marked complete~' + ], + 'Completion incomplete Section with previous activity incompleted' => [ + COMPLETION_INCOMPLETE, false, 'section2', true, false, '~Page1!.*is incomplete~' + ], + 'Completion complete Section with previous activity completed' => [ + COMPLETION_COMPLETE, true, 'section2', true, false, '~Page1!.*is marked complete~' + ], + 'Completion incomplete Section with previous activity completed' => [ + COMPLETION_INCOMPLETE, true, 'section2', false, true, '~Page1!.*is incomplete~' + ], + // Section 3 depending on section 1 -> Page 1 (no grading). + 'Completion complete Section ignoring empty sections and activity incompleted' => [ + COMPLETION_COMPLETE, false, 'section3', false, true, '~Page1!.*is marked complete~' + ], + 'Completion incomplete Section ignoring empty sections and activity incompleted' => [ + COMPLETION_INCOMPLETE, false, 'section3', true, false, '~Page1!.*is incomplete~' + ], + 'Completion complete Section ignoring empty sections and activity completed' => [ + COMPLETION_COMPLETE, true, 'section3', true, false, '~Page1!.*is marked complete~' + ], + 'Completion incomplete Section ignoring empty sections and activity completed' => [ + COMPLETION_INCOMPLETE, true, 'section3', false, true, '~Page1!.*is incomplete~' + ], + // Section 4 depending on section 3 -> Page 2 (no grading). + 'Completion complete Last section with previous activity incompleted' => [ + COMPLETION_COMPLETE, false, 'section4', false, true, '~Page2!.*is marked complete~' + ], + 'Completion incomplete Last section with previous activity incompleted' => [ + COMPLETION_INCOMPLETE, false, 'section4', true, false, '~Page2!.*is incomplete~' + ], + 'Completion complete Last section with previous activity completed' => [ + COMPLETION_COMPLETE, true, 'section4', false, true, '~Page2!.*is marked complete~' + ], + 'Completion incomplete Last section with previous activity completed' => [ + COMPLETION_INCOMPLETE, true, 'section4', true, false, '~Page2!.*is incomplete~' + ], + ]; + } + /** * Tests completion_value_used static function. */ public function test_completion_value_used() { global $CFG, $DB; $this->resetAfterTest(); + $prevvalue = condition::OPTION_PREVIOUS; // Create course with completion turned on and some sections. $CFG->enablecompletion = true; $CFG->enableavailability = true; $generator = $this->getDataGenerator(); $course = $generator->create_course( - array('numsections' => 1, 'enablecompletion' => 1), - array('createsections' => true)); - availability_completion\condition::wipe_static_cache(); + ['numsections' => 1, 'enablecompletion' => 1], + ['createsections' => true]); - // Create three pages with manual completion. + // Create six pages with manual completion. $page1 = $generator->get_plugin_generator('mod_page')->create_instance( - array('course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL)); + ['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]); $page2 = $generator->get_plugin_generator('mod_page')->create_instance( - array('course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL)); + ['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]); $page3 = $generator->get_plugin_generator('mod_page')->create_instance( - array('course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL)); + ['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]); + $page4 = $generator->get_plugin_generator('mod_page')->create_instance( + ['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]); + $page5 = $generator->get_plugin_generator('mod_page')->create_instance( + ['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]); + $page6 = $generator->get_plugin_generator('mod_page')->create_instance( + ['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]); // Set up page3 to depend on page1, and section1 to depend on page2. $DB->set_field('course_modules', 'availability', '{"op":"|","show":true,"c":[' . '{"type":"completion","e":1,"cm":' . $page1->cmid . '}]}', - array('id' => $page3->cmid)); + ['id' => $page3->cmid]); $DB->set_field('course_sections', 'availability', '{"op":"|","show":true,"c":[' . '{"type":"completion","e":1,"cm":' . $page2->cmid . '}]}', - array('course' => $course->id, 'section' => 1)); + ['course' => $course->id, 'section' => 1]); + // Set up page5 and page6 to depend on previous activity. + $DB->set_field('course_modules', 'availability', + '{"op":"|","show":true,"c":[' . + '{"type":"completion","e":1,"cm":' . $prevvalue . '}]}', + ['id' => $page5->cmid]); + $DB->set_field('course_modules', 'availability', + '{"op":"|","show":true,"c":[' . + '{"type":"completion","e":1,"cm":' . $prevvalue . '}]}', + ['id' => $page6->cmid]); - // Now check: nothing depends on page3 but something does on the others. + // Check 1: nothing depends on page3 and page6 but something does on the others. $this->assertTrue(availability_completion\condition::completion_value_used( $course, $page1->cmid)); $this->assertTrue(availability_completion\condition::completion_value_used( $course, $page2->cmid)); $this->assertFalse(availability_completion\condition::completion_value_used( $course, $page3->cmid)); + $this->assertTrue(availability_completion\condition::completion_value_used( + $course, $page4->cmid)); + $this->assertTrue(availability_completion\condition::completion_value_used( + $course, $page5->cmid)); + $this->assertFalse(availability_completion\condition::completion_value_used( + $course, $page6->cmid)); } /** @@ -390,9 +763,10 @@ class availability_completion_condition_testcase extends advanced_testcase { * @param float $grade Grade */ protected static function set_grade($assignrow, $userid, $grade) { - $grades = array(); - $grades[$userid] = (object)array( - 'rawgrade' => $grade, 'userid' => $userid); + $grades = []; + $grades[$userid] = (object)[ + 'rawgrade' => $grade, 'userid' => $userid + ]; $assignrow->cmidnumber = null; assign_grade_item_update($assignrow, $grades); } @@ -401,12 +775,32 @@ class availability_completion_condition_testcase extends advanced_testcase { * Tests the update_dependency_id() function. */ public function test_update_dependency_id() { - $cond = new condition((object)array( - 'cm' => 123, 'e' => COMPLETION_COMPLETE)); - $this->assertFalse($cond->update_dependency_id('frogs', 123, 456)); + $cond = new condition((object)[ + 'cm' => 42, 'e' => COMPLETION_COMPLETE, 'selfid' => 43 + ]); + $this->assertFalse($cond->update_dependency_id('frogs', 42, 540)); $this->assertFalse($cond->update_dependency_id('course_modules', 12, 34)); - $this->assertTrue($cond->update_dependency_id('course_modules', 123, 456)); + $this->assertTrue($cond->update_dependency_id('course_modules', 42, 456)); $after = $cond->save(); $this->assertEquals(456, $after->cm); + + // Test selfid updating. + $cond = new condition((object)[ + 'cm' => 42, 'e' => COMPLETION_COMPLETE + ]); + $this->assertFalse($cond->update_dependency_id('frogs', 43, 540)); + $this->assertFalse($cond->update_dependency_id('course_modules', 12, 34)); + $after = $cond->save(); + $this->assertEquals(42, $after->cm); + + // Test on previous activity. + $cond = new condition((object)[ + 'cm' => condition::OPTION_PREVIOUS, + 'e' => COMPLETION_COMPLETE + ]); + $this->assertFalse($cond->update_dependency_id('frogs', 43, 80)); + $this->assertFalse($cond->update_dependency_id('course_modules', 12, 34)); + $after = $cond->save(); + $this->assertEquals(condition::OPTION_PREVIOUS, $after->cm); } } diff --git a/availability/condition/completion/version.php b/availability/condition/completion/version.php index 71d62819749..4e1a55f8f1c 100644 --- a/availability/condition/completion/version.php +++ b/availability/condition/completion/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2019111800; +$plugin->version = 2020032600; $plugin->requires = 2019111200; $plugin->component = 'availability_completion'; diff --git a/availability/tests/fixtures/mock_info_module.php b/availability/tests/fixtures/mock_info_module.php new file mode 100644 index 00000000000..47f75cc3fa7 --- /dev/null +++ b/availability/tests/fixtures/mock_info_module.php @@ -0,0 +1,115 @@ +. + +/** + * For use in unit tests that require an info module which isn't really used. + * + * @package core_availability + * @copyright 2019 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_availability; + +defined('MOODLE_INTERNAL') || die(); + +/** + * For use in unit tests that require an info module which isn't really used. + * + * @package core_availability + * @copyright 2019 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mock_info_module extends info_module { + /** @var int User id for modinfo */ + protected $userid; + + /** @var \cm_info Activity. */ + protected $cm; + + /** + * Constructs with item details. + * + * @param int $userid Userid for modinfo (if used) + * @param \cm_info $cm Course-module object + */ + public function __construct($userid = 0, \cm_info $cm = null) { + parent::__construct($cm); + $this->userid = $userid; + $this->cm = $cm; + } + + /** + * Just returns a mock name. + * + * @return string Name of item + */ + protected function get_thing_name() { + return 'Mock Module'; + } + + /** + * Returns the current context. + * + * @return \context Context for this item + */ + public function get_context() { + return \context_course::instance($this->get_course()->id); + } + + /** + * Returns the cappability used to ignore access restrictions. + * + * @return string Name of capability used to view hidden items of this type + */ + protected function get_view_hidden_capability() { + return 'moodle/course:ignoreavailabilityrestrictions'; + } + + /** + * Mocks don't need to save anything into DB. + * + * @param string $availability New JSON value + */ + protected function set_in_database($availability) { + } + + /** + * Obtains the modinfo associated with this availability information. + * + * Note: This field is available ONLY for use by conditions when calculating + * availability or information. + * + * @return \course_modinfo Modinfo + * @throws \coding_exception If called at incorrect times + */ + public function get_modinfo() { + // Allow modinfo usage outside is_available etc., so we can use this + // to directly call into condition is_available. + if (!$this->userid) { + throw new \coding_exception('Need to set mock_info userid'); + } + return get_fast_modinfo($this->course, $this->userid); + } + + /** + * Override course-module info. + * @param \cm_info $cm + */ + public function set_cm (\cm_info $cm) { + $this->cm = $cm; + } +} diff --git a/availability/tests/fixtures/mock_info_section.php b/availability/tests/fixtures/mock_info_section.php new file mode 100644 index 00000000000..15fba4597c2 --- /dev/null +++ b/availability/tests/fixtures/mock_info_section.php @@ -0,0 +1,116 @@ +. + +/** + * For use in unit tests that require an info section which isn't really used. + * + * @package core_availability + * @copyright 2019 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_availability; + +defined('MOODLE_INTERNAL') || die(); + +/** + * For use in unit tests that require an info section which isn't really used. + * + * @package core_availability + * @copyright 2019 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mock_info_section extends info_section { + /** @var int User id for modinfo */ + protected $userid; + + /** @var \section_info Section. */ + protected $section; + + /** + * Constructs with item details. + * + * @param int $userid Userid for modinfo (if used) + * @param \section_info $section Section object + */ + public function __construct($userid = 0, \section_info $section = null) { + parent::__construct($section); + $this->userid = $userid; + $this->section = $section; + } + + /** + * Just returns a mock name. + * + * @return string Name of item + */ + protected function get_thing_name() { + return 'Mock Section'; + } + + /** + * Returns the current context. + * + * @return \context Context for this item + */ + public function get_context() { + return \context_course::instance($this->get_course()->id); + } + + /** + * Returns the cappability used to ignore access restrictions. + * + * @return string Name of capability used to view hidden items of this type + */ + protected function get_view_hidden_capability() { + return 'moodle/course:ignoreavailabilityrestrictions'; + } + + /** + * Mocks don't need to save anything into DB. + * + * @param string $availability New JSON value + */ + protected function set_in_database($availability) { + } + + /** + * Obtains the modinfo associated with this availability information. + * + * Note: This field is available ONLY for use by conditions when calculating + * availability or information. + * + * @return \course_modinfo Modinfo + * @throws \coding_exception If called at incorrect times + */ + public function get_modinfo() { + // Allow modinfo usage outside is_available etc., so we can use this + // to directly call into condition is_available. + if (!$this->userid) { + throw new \coding_exception('Need to set mock_info userid'); + } + return get_fast_modinfo($this->course, $this->userid); + } + + /** + * Override section info. + * + * @param \section_info $section + */ + public function set_section (\section_info $section) { + $this->section = $section; + } +}