diff --git a/availability/classes/info_base.php b/availability/classes/info.php similarity index 100% rename from availability/classes/info_base.php rename to availability/classes/info.php diff --git a/availability/condition/completion/classes/condition.php b/availability/condition/completion/classes/condition.php new file mode 100644 index 00000000000..d44ff2fd08a --- /dev/null +++ b/availability/condition/completion/classes/condition.php @@ -0,0 +1,269 @@ +. + +/** + * Activity completion condition. + * + * @package availability_completion + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace availability_completion; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir . '/completionlib.php'); + +/** + * Activity completion condition. + * + * @package availability_completion + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class condition extends \core_availability\condition { + /** @var int ID of module that this depends on */ + protected $cmid; + + /** @var int Expected completion type (one of the COMPLETE_xx constants) */ + protected $expectedcompletion; + + /** @var array Array of modules used in these conditions for course */ + protected static $modsusedincondition = array(); + + /** + * Constructor. + * + * @param stdClass $structure Data structure from JSON decode + * @throws coding_exception If invalid data structure. + */ + public function __construct($structure) { + // Get cmid. + if (isset($structure->cm) && is_int($structure->cm)) { + $this->cmid = $structure->cm; + } 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))) { + $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); + } + + public function is_available($not, \core_availability\info $info, $grabthelot, $userid) { + $modinfo = $info->get_modinfo(); + $completion = new \completion_info($modinfo->get_course()); + if (!array_key_exists($this->cmid, $modinfo->cms)) { + // 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), + $grabthelot, $userid, $modinfo); + + $allow = true; + if ($this->expectedcompletion == COMPLETION_COMPLETE) { + // Complete also allows the pass, fail states. + switch ($completiondata->completionstate) { + case COMPLETION_COMPLETE: + case COMPLETION_COMPLETE_FAIL: + case COMPLETION_COMPLETE_PASS: + break; + default: + $allow = false; + } + } else { + // Other values require exact match. + if ($completiondata->completionstate != $this->expectedcompletion) { + $allow = false; + } + } + + if ($not) { + $allow = !$allow; + } + } + + return $allow; + } + + /** + * Returns a more readable keyword corresponding to a completion state. + * + * Used to make lang strings easier to read. + * + * @param int $completionstate COMPLETION_xx constant + * @return string Readable keyword + */ + protected static function get_lang_string_keyword($completionstate) { + switch($completionstate) { + case COMPLETION_INCOMPLETE: + return 'incomplete'; + case COMPLETION_COMPLETE: + return 'complete'; + case COMPLETION_COMPLETE_PASS: + return 'complete_pass'; + case COMPLETION_COMPLETE_FAIL: + return 'complete_fail'; + default: + throw new \coding_exception('Unexpected completion state: ' . $completionstate); + } + } + + 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'); + } else { + $modname = 'cms[$this->cmid]->id . '/>'; + } + + // Work out which lang string to use. + 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); + break; + case COMPLETION_COMPLETE: + $str = 'requires_' . 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); + break; + } + } else { + $str = 'requires_' . self::get_lang_string_keyword($this->expectedcompletion); + } + + return get_string($str, 'availability_completion', $modname); + } + + protected function get_debug_string() { + switch ($this->expectedcompletion) { + case COMPLETION_COMPLETE : + $type = 'COMPLETE'; + break; + case COMPLETION_INCOMPLETE : + $type = 'INCOMPLETE'; + break; + case COMPLETION_COMPLETE_PASS: + $type = 'COMPLETE_PASS'; + break; + case COMPLETION_COMPLETE_FAIL: + $type = 'COMPLETE_FAIL'; + break; + default: + throw new \coding_exception('Unexpected expected completion'); + } + return 'cm' . $this->cmid . ' ' . $type; + } + + public function update_after_restore($restoreid, $courseid, \base_logger $logger, $name) { + global $DB; + $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; + } + // Otherwise it's a warning. + $this->cmid = 0; + $logger->process('Restored item (' . $name . + ') has availability condition on module that was not restored', + \backup::LOG_WARNING); + } else { + $this->cmid = (int)$rec->newitemid; + } + return true; + } + + /** + * Used in course/lib.php because we need to disable the completion JS if + * a completion value affects a conditional activity. + * + * @param stdClass $course Moodle course object + * @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) { + // 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(); + + // Activities. + foreach ($modinfo->cms as $othercm) { + if (is_null($othercm->availability)) { + continue; + } + $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; + } + } + + // Sections. + foreach ($modinfo->get_section_info_all() as $section) { + if (is_null($section->availability)) { + continue; + } + $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; + } + } + } + return array_key_exists($cmid, self::$modsusedincondition[$course->id]); + } + + /** + * Wipes the static cache of modules used in a condition (for unit testing). + */ + public static function wipe_static_cache() { + self::$modsusedincondition = array(); + } + + public function update_dependency_id($table, $oldid, $newid) { + if ($table === 'course_modules' && (int)$this->cmid === (int)$oldid) { + $this->cmid = $newid; + return true; + } else { + return false; + } + } +} diff --git a/availability/condition/completion/classes/frontend.php b/availability/condition/completion/classes/frontend.php new file mode 100644 index 00000000000..02dad76870d --- /dev/null +++ b/availability/condition/completion/classes/frontend.php @@ -0,0 +1,94 @@ +. + +/** + * Front-end class. + * + * @package availability_completion + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace availability_completion; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Front-end class. + * + * @package availability_completion + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class frontend extends \core_availability\frontend { + /** + * @var array Cached init parameters + */ + protected $cacheparams = array(); + + /** + * @var string IDs of course, cm, and section for cache (if any) + */ + protected $cachekey = ''; + + protected function get_javascript_strings() { + return array('option_complete', 'option_fail', 'option_incomplete', 'option_pass', + 'label_cm', 'label_completion'); + } + + protected function get_javascript_init_params($course, \cm_info $cm = null, + \section_info $section = null) { + // Use cached result if available. The cache is just because we call it + // twice (once from allow_add) so it's nice to avoid doing all the + // print_string calls twice. + $cachekey = $course->id . ',' . ($cm ? $cm->id : '') . ($section ? $section->id : ''); + if ($cachekey !== $this->cachekey) { + // Get list of activities on course which have completion values, + // to fill the dropdown. + $context = \context_course::instance($course->id); + $cms = array(); + $modinfo = get_fast_modinfo($course); + 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)) { + $cms[] = (object)array('id' => $id, 'name' => + format_string($othercm->name, true, array('context' => $context))); + } + } + + $this->cachekey = $cachekey; + $this->cacheinitparams = array($cms); + } + return $this->cacheinitparams; + } + + protected function allow_add($course, \cm_info $cm = null, + \section_info $section = null) { + global $CFG; + + // Check if completion is enabled for the course. + require_once($CFG->libdir . '/completionlib.php'); + $info = new \completion_info($course); + if (!$info->is_enabled()) { + return false; + } + + // Check if there's at least one other module with completion info. + $params = $this->get_javascript_init_params($course, $cm, $section); + return ((array)$params[0]) != false; + } +} diff --git a/availability/condition/completion/lang/en/availability_completion.php b/availability/condition/completion/lang/en/availability_completion.php new file mode 100644 index 00000000000..aa41e647de8 --- /dev/null +++ b/availability/condition/completion/lang/en/availability_completion.php @@ -0,0 +1,41 @@ +. + +/** + * Language strings. + * + * @package availability_completion + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['description'] = 'Require students to complete (or not complete) another activity.'; +$string['error_selectcmid'] = 'You must select an activity for the completion condition.'; +$string['label_cm'] = 'Activity or resource'; +$string['label_completion'] = 'Required completion status'; +$string['missing'] = '(Missing activity)'; +$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['pluginname'] = 'Restriction by activity completion'; +$string['requires_incomplete'] = 'The activity {$a} is incomplete'; +$string['requires_complete'] = 'The activity {$a} is marked complete'; +$string['requires_complete_pass'] = 'The activity {$a} is complete and passed'; +$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['title'] = 'Activity completion'; diff --git a/availability/condition/completion/tests/behat/availability_completion.feature b/availability/condition/completion/tests/behat/availability_completion.feature new file mode 100644 index 00000000000..e10a8b6603f --- /dev/null +++ b/availability/condition/completion/tests/behat/availability_completion.feature @@ -0,0 +1,62 @@ +@availability @availability_completion +Feature: availability_completion + 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 | + | Course 1 | C1 | topics | 1 | + And the following "users" exist: + | username | + | teacher1 | + | student1 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And I log in as "admin" + And I set the following administration settings values: + | Enable conditional access | 1 | + | Enable completion tracking | 1 | + And I log out + + @javascript + Scenario: Test condition + # Basic setup. + Given I log in as "teacher1" + And I follow "Course 1" + And I turn editing mode on + + # Add a Page with a completion tickbox. + And I add a "Page" to section "1" and I fill the form with: + | Name | Page 1 | + | Description | Test | + | Page content | Test | + | Completion tracking | 1 | + + # And another one that depends on it (hidden otherwise). + And I add a "Page" to section "2" + And I set the following fields to these values: + | Name | Page 2 | + | Description | Test | + | Page content | Test | + 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 ".availability-item .availability-eye img" "css_element" + And I set the field "Activity or resource" to "Page 1" + And I press "Save and return to course" + + # Log back in as student. + When I log out + And I log in as "student1" + And I follow "Course 1" + + # Page 2 should not appear yet. + Then I should not see "Page 2" in the "region-main" "region" + + # Mark page 1 complete + When I click on ".togglecompletion input[type=image]" "css_element" + Then I should see "Page 2" in the "region-main" "region" diff --git a/availability/condition/completion/tests/condition_test.php b/availability/condition/completion/tests/condition_test.php new file mode 100644 index 00000000000..c64be08752c --- /dev/null +++ b/availability/condition/completion/tests/condition_test.php @@ -0,0 +1,412 @@ +. + +/** + * Unit tests for the completion condition. + * + * @package availability_completion + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +use availability_completion\condition; + +global $CFG; +require_once($CFG->libdir . '/completionlib.php'); + +/** + * Unit tests for the completion condition. + * + * @package availability_completion + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class availability_completion_condition_testcase extends advanced_testcase { + /** + * 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'); + } + + /** + * Tests constructing and using condition as part of tree. + */ + public function test_in_tree() { + global $USER, $CFG; + $this->resetAfterTest(); + + $this->setAdminUser(); + + // Create course with completion turned on and a Page. + $CFG->enablecompletion = true; + $CFG->enableavailability = true; + $generator = $this->getDataGenerator(); + $course = $generator->create_course(array('enablecompletion' => 1)); + $page = $generator->get_plugin_generator('mod_page')->create_instance( + array('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))); + $tree = new \core_availability\tree($structure); + + // Initial check (user has not completed activity). + $result = $tree->check_available(false, $info, true, $USER->id); + $this->assertFalse($result->is_available()); + + // Mark activity complete. + $completion = new completion_info($course); + $completion->update_state($cm, COMPLETION_COMPLETE); + + // Now it's true! + $result = $tree->check_available(false, $info, true, $USER->id); + $this->assertTrue($result->is_available()); + } + + /** + * Tests the constructor including error conditions. Also tests the + * string conversion feature (intended for debugging only). + */ + public function test_constructor() { + // No parameters. + $structure = new stdClass(); + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing or invalid ->cm', $e->getMessage()); + } + + // Invalid $cm. + $structure->cm = 'hello'; + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing or invalid ->cm', $e->getMessage()); + } + + // Missing $e. + $structure->cm = 42; + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing or invalid ->e', $e->getMessage()); + } + + // Invalid $e. + $structure->e = 99; + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing or invalid ->e', $e->getMessage()); + } + + // Successful construct & display with all different expected values. + $structure->e = COMPLETION_COMPLETE; + $cond = new condition($structure); + $this->assertEquals('{completion:cm42 COMPLETE}', (string)$cond); + + $structure->e = COMPLETION_COMPLETE_PASS; + $cond = new condition($structure); + $this->assertEquals('{completion:cm42 COMPLETE_PASS}', (string)$cond); + + $structure->e = COMPLETION_COMPLETE_FAIL; + $cond = new condition($structure); + $this->assertEquals('{completion:cm42 COMPLETE_FAIL}', (string)$cond); + + $structure->e = COMPLETION_INCOMPLETE; + $cond = new condition($structure); + $this->assertEquals('{completion:cm42 INCOMPLETE}', (string)$cond); + } + + /** + * Tests the save() function. + */ + public function test_save() { + $structure = (object)array('cm' => 42, 'e' => COMPLETION_COMPLETE); + $cond = new condition($structure); + $structure->type = 'completion'; + $this->assertEquals($structure, $cond->save()); + } + + /** + * Tests the is_available and get_description functions. + */ + public function test_usage() { + 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(array('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)); + + // 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)); + $DB->set_field('course_modules', 'completiongradeitemnumber', 0, + array('id' => $assignrow->cmid)); + $assign = new assign(context_module::instance($assignrow->cmid), false, false); + + // Get basic details. + $modinfo = get_fast_modinfo($course); + $pagecm = $modinfo->get_cm($page->cmid); + $assigncm = $assign->get_course_module(); + $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)); + $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('~Page!.*is marked complete~', $information); + $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)); + $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('~Page!.*is marked complete~', $information); + + // Mark page complete. + $completion = new completion_info($course); + $completion->update_state($pagecm, COMPLETION_COMPLETE); + + // COMPLETE state (true). + $cond = new condition((object)array( + '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); + $information = \core_availability\info::format_info($information, $course); + $this->assertRegExp('~Page!.*is incomplete~', $information); + + // INCOMPLETE state (false). + $cond = new condition((object)array( + '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); + $this->assertRegExp('~Page!.*is incomplete~', $information); + $this->assertTrue($cond->is_available(true, $info, + true, $user->id)); + + // 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)); + $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)); + $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)); + $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)); + $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)); + $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 failed~', $information); + $this->assertTrue($cond->is_available(true, $info, true, $user->id)); + + // 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)); + $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)); + $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)); + $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)); + $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 failed~', $information); + + // Now change it to pass. + self::set_grade($assignrow, $user->id, 60); + + $cond = new condition((object)array( + '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)); + $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)); + $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)); + $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 failed~', $information); + $this->assertTrue($cond->is_available(true, $info, true, $user->id)); + + // 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)); + $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)); + $this->assertFalse($cond->is_available(false, $info, true, $user->id)); + } + + /** + * Tests completion_value_used static function. + */ + public function test_completion_value_used() { + global $CFG, $DB; + $this->resetAfterTest(); + + // 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(); + + // Create three pages with manual completion. + $page1 = $generator->get_plugin_generator('mod_page')->create_instance( + array('course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL)); + $page2 = $generator->get_plugin_generator('mod_page')->create_instance( + array('course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL)); + $page3 = $generator->get_plugin_generator('mod_page')->create_instance( + array('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)); + $DB->set_field('course_sections', 'availability', + '{"op":"|","show":true,"c":[' . + '{"type":"completion","e":1,"cm":' . $page2->cmid . '}]}', + array('course' => $course->id, 'section' => 1)); + + // Now check: nothing depends on page3 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)); + } + + /** + * Updates the grade of a user in the given assign module instance. + * + * @param stdClass $assignrow Assignment row from database + * @param int $userid User id + * @param float $grade Grade + */ + protected static function set_grade($assignrow, $userid, $grade) { + $grades = array(); + $grades[$userid] = (object)array( + 'rawgrade' => $grade, 'userid' => $userid); + $assignrow->cmidnumber = null; + assign_grade_item_update($assignrow, $grades); + } + + /** + * 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)); + $this->assertFalse($cond->update_dependency_id('course_modules', 12, 34)); + $this->assertTrue($cond->update_dependency_id('course_modules', 123, 456)); + $after = $cond->save(); + $this->assertEquals(456, $after->cm); + } +} diff --git a/availability/condition/completion/version.php b/availability/condition/completion/version.php new file mode 100644 index 00000000000..821c41c766a --- /dev/null +++ b/availability/condition/completion/version.php @@ -0,0 +1,29 @@ +. + +/** + * Version info. + * + * @package availability_completion + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->version = 2014022800; +$plugin->requires = 2014040401; +$plugin->component = 'availability_completion'; diff --git a/availability/condition/completion/yui/src/form/build.json b/availability/condition/completion/yui/src/form/build.json new file mode 100644 index 00000000000..655dc8f6496 --- /dev/null +++ b/availability/condition/completion/yui/src/form/build.json @@ -0,0 +1,10 @@ +{ + "name": "moodle-availability_completion-form", + "builds": { + "moodle-availability_completion-form": { + "jsfiles": [ + "form.js" + ] + } + } +} diff --git a/availability/condition/completion/yui/src/form/js/form.js b/availability/condition/completion/yui/src/form/js/form.js new file mode 100644 index 00000000000..b37ec5e865d --- /dev/null +++ b/availability/condition/completion/yui/src/form/js/form.js @@ -0,0 +1,77 @@ +/** + * JavaScript for form editing completion conditions. + * + * @module moodle-availability_completion-form + */ +M.availability_completion = M.availability_completion || {}; + +/** + * @class M.availability_completion.form + * @extends M.core_availability.plugin + */ +M.availability_completion.form = Y.Object(M.core_availability.plugin); + +/** + * Initialises this plugin. + * + * @method initInner + * @param {Array} cms Array of objects containing cmid => name + */ +M.availability_completion.form.initInner = function(cms) { + this.cms = cms; +}; + +M.availability_completion.form.getNode = function(json) { + // Create HTML structure. + var strings = M.str.availability_completion; + var html = strings.title + ' '; + var node = Y.Node.create('' + html + ''); + + // Set initial values. + if (json.cm !== undefined && + node.one('select[name=cm] > option[value=' + json.cm + ']')) { + node.one('select[name=cm]').set('value', json.cm); + } + if (json.e !== undefined) { + node.one('select[name=e]').set('value', json.e); + } + + // Add event handlers (first time only). + if (!M.availability_completion.form.addedEvents) { + M.availability_completion.form.addedEvents = true; + var root = Y.one('#fitem_id_availabilityconditionsjson'); + root.delegate('change', function() { + // Whichever dropdown changed, just update the form. + M.core_availability.form.update(); + }, '.availability_completion select'); + } + + return node; +}; + +M.availability_completion.form.fillValue = function(value, node) { + value.cm = parseInt(node.one('select[name=cm]').get('value'), 10); + value.e = parseInt(node.one('select[name=e]').get('value'), 10); +}; + +M.availability_completion.form.fillErrors = function(errors, node) { + var cmid = parseInt(node.one('select[name=cm]').get('value'), 10); + if (cmid === 0) { + errors.push('availability_completion:error_selectcmid'); + } +}; diff --git a/availability/condition/completion/yui/src/form/meta/form.json b/availability/condition/completion/yui/src/form/meta/form.json new file mode 100644 index 00000000000..ef4262ccfc4 --- /dev/null +++ b/availability/condition/completion/yui/src/form/meta/form.json @@ -0,0 +1,10 @@ +{ + "moodle-availability_completion-form": { + "requires": [ + "base", + "node", + "event", + "moodle-core_availability-form" + ] + } +} diff --git a/availability/condition/date/ajax.php b/availability/condition/date/ajax.php new file mode 100644 index 00000000000..72eea749073 --- /dev/null +++ b/availability/condition/date/ajax.php @@ -0,0 +1,51 @@ +. + +/** + * Handles AJAX processing (convert date to timestamp using current calendar). + * + * @package availability_date + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +define('AJAX_SCRIPT', true); +require(__DIR__ . '/../../../config.php'); + +// Action verb. +$action = required_param('action', PARAM_ALPHA); + +switch ($action) { + case 'totime': + // Converts from time fields to timestamp using current user's calendar and time zone. + echo \availability_date\frontend::get_time_from_fields( + required_param('year', PARAM_INT), + required_param('month', PARAM_INT), + required_param('day', PARAM_INT), + required_param('hour', PARAM_INT), + required_param('minute', PARAM_INT)); + exit; + + case 'fromtime' : + // Converts from timestamp to time fields. + echo json_encode(\availability_date\frontend::get_fields_from_time( + required_param('time', PARAM_INT))); + exit; +} + +// Unexpected actions throw coding_exception (this error should not occur +// unless there is a code bug). +throw new coding_exception('Unexpected action parameter'); \ No newline at end of file diff --git a/availability/condition/date/classes/condition.php b/availability/condition/date/classes/condition.php new file mode 100644 index 00000000000..3c048274dc8 --- /dev/null +++ b/availability/condition/date/classes/condition.php @@ -0,0 +1,213 @@ +. + +/** + * Date condition. + * + * @package availability_date + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace availability_date; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Date condition. + * + * @package availability_date + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class condition extends \core_availability\condition { + /** @var string Availabile only from specified date. */ + const DIRECTION_FROM = '>='; + + /** @var string Availabile only until specified date. */ + const DIRECTION_UNTIL = '<'; + + /** @var string One of the DIRECTION_xx constants. */ + private $direction; + + /** @var int Time (Unix epoch seconds) for condition. */ + private $time; + + /** @var int Forced current time (for unit tests) or 0 for normal. */ + private static $forcecurrenttime = 0; + + /** + * Constructor. + * + * @param stdClass $structure Data structure from JSON decode + * @throws coding_exception If invalid data structure. + */ + public function __construct($structure) { + // Get direction. + if (isset($structure->d) && in_array($structure->d, + array(self::DIRECTION_FROM, self::DIRECTION_UNTIL))) { + $this->direction = $structure->d; + } else { + throw new \coding_exception('Missing or invalid ->d for date condition'); + } + + // Get time. + if (isset($structure->t) && is_int($structure->t)) { + $this->time = $structure->t; + } else { + throw new \coding_exception('Missing or invalid ->t for date condition'); + } + } + + public function save() { + return (object)array('type' => 'date', + 'd' => $this->direction, 't' => $this->time); + } + + public function is_available($not, \core_availability\info $info, $grabthelot, $userid) { + return $this->is_available_for_all($not); + } + + public function is_available_for_all($not = false) { + // Check condition. + $now = self::get_time(); + switch ($this->direction) { + case self::DIRECTION_FROM: + $allow = $now >= $this->time; + break; + case self::DIRECTION_UNTIL: + $allow = $now < $this->time; + break; + default: + throw new \coding_exception('Unexpected direction'); + } + if ($not) { + $allow = !$allow; + } + + return $allow; + } + + /** + * Obtains the actual direction of checking based on the $not value. + * + * @param bool $not True if condition is negated + * @return string Direction constant + * @throws \coding_exception + */ + protected function get_logical_direction($not) { + switch ($this->direction) { + case self::DIRECTION_FROM: + return $not ? self::DIRECTION_UNTIL : self::DIRECTION_FROM; + case self::DIRECTION_UNTIL: + return $not ? self::DIRECTION_FROM : self::DIRECTION_UNTIL; + default: + throw new \coding_exception('Unexpected direction'); + } + } + + public function get_description($full, $not, \core_availability\info $info) { + return $this->get_either_description($not, false); + } + + public function get_standalone_description( + $full, $not, \core_availability\info $info) { + return $this->get_either_description($not, true); + } + + /** + * Shows the description using the different lang strings for the standalone + * version or the full one. + * + * @param bool $not True if NOT is in force + * @param bool $standalone True to use standalone lang strings + */ + protected function get_either_description($not, $standalone) { + $direction = $this->get_logical_direction($not); + $midnight = self::is_midnight($this->time); + $midnighttag = $midnight ? '_date' : ''; + $satag = $standalone ? 'short_' : 'full_'; + switch ($direction) { + case self::DIRECTION_FROM: + return get_string($satag . 'from' . $midnighttag, 'availability_date', + self::show_time($this->time, $midnight, false)); + case self::DIRECTION_UNTIL: + return get_string($satag . 'until' . $midnighttag, 'availability_date', + self::show_time($this->time, $midnight, true)); + } + } + + protected function get_debug_string() { + return $this->direction . ' ' . gmdate('Y-m-d H:i:s', $this->time); + } + + /** + * Gets time. This function is implemented here rather than calling time() + * so that it can be overridden in unit tests. (Would really be nice if + * Moodle had a generic way of doing that, but it doesn't.) + * + * @return int Current time (seconds since epoch) + */ + protected static function get_time() { + if (self::$forcecurrenttime) { + return self::$forcecurrenttime; + } else { + return time(); + } + } + + /** + * Forces the current time for unit tests. + * + * @param int $forcetime Time to return from the get_time function + */ + public static function set_current_time_for_test($forcetime = 0) { + self::$forcecurrenttime = $forcetime; + } + + /** + * Shows a time either as a date or a full date and time, according to + * user's timezone. + * + * @param int $time Time + * @param bool $dateonly If true, uses date only + * @param bool $until If true, and if using date only, shows previous date + * @return string Date + */ + protected function show_time($time, $dateonly, $until = false) { + // For 'until' dates that are at midnight, e.g. midnight 5 March, it + // is better to word the text as 'until end 4 March'. + $daybefore = false; + if ($until && $dateonly) { + $daybefore = true; + $time = strtotime('-1 day', $time); + } + return userdate($time, + get_string($dateonly ? 'strftimedate' : 'strftimedatetime', 'langconfig')); + } + + /** + * Checks whether a given time refers exactly to midnight (in current user + * timezone). + * + * @param int $time Time + * @return bool True if time refers to midnight, false if it's some other + * time or if it is set to zero + */ + protected static function is_midnight($time) { + return $time && usergetmidnight($time) == $time; + } +} diff --git a/availability/condition/date/classes/frontend.php b/availability/condition/date/classes/frontend.php new file mode 100644 index 00000000000..e1cba19fca6 --- /dev/null +++ b/availability/condition/date/classes/frontend.php @@ -0,0 +1,174 @@ +. + +/** + * Front-end class. + * + * @package availability_date + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace availability_date; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Front-end class. + * + * @package availability_date + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class frontend extends \core_availability\frontend { + /** + * The date selector popup is not currently supported because the date + * selector is in a messy state (about to be replaced with a new YUI3 + * implementation) and MDL-44814 was rejected. I have left the code in + * place, but disabled. When the date selector situation is finalised, + * then this constant should be removed (either applying MDL-44814 if old + * selector is still in use, or modifying the JavaScript code to support the + * new date selector if it has landed). + * + * @var bool + */ + const DATE_SELECTOR_SUPPORTED = false; + + protected function get_javascript_strings() { + return array('ajaxerror', 'direction_before', 'direction_from', 'direction_until', + 'direction_label'); + } + + /** + * Given field values, obtains the corresponding timestamp. + * + * @param int $year Year + * @param int $month Month + * @param int $day Day + * @param int $hour Hour + * @param int $minute Minute + * @return int Timestamp + */ + public static function get_time_from_fields($year, $month, $day, $hour, $minute) { + $calendartype = \core_calendar\type_factory::get_calendar_instance(); + $gregoriandate = $calendartype->convert_to_gregorian( + $year, $month, $day, $hour, $minute); + return make_timestamp($gregoriandate['year'], $gregoriandate['month'], + $gregoriandate['day'], $gregoriandate['hour'], $gregoriandate['minute'], 0); + } + + /** + * Given a timestamp, obtains corresponding field values. + * + * @param int $time Timestamp + * @return stdClass Object with fields for year, month, day, hour, minute + */ + public static function get_fields_from_time($time) { + $calendartype = \core_calendar\type_factory::get_calendar_instance(); + $wrongfields = $calendartype->timestamp_to_date_array($time); + return array('day' => $wrongfields['mday'], + 'month' => $wrongfields['mon'], 'year' => $wrongfields['year'], + 'hour' => $wrongfields['hours'], 'minute' => $wrongfields['minutes']); + } + + protected function get_javascript_init_params($course, \cm_info $cm = null, + \section_info $section = null) { + global $CFG, $OUTPUT; + require_once($CFG->libdir . '/formslib.php'); + + // Support internationalised calendars. + $calendartype = \core_calendar\type_factory::get_calendar_instance(); + + // Get current date, but set time to 00:00 (to make it easier to + // specify whole days) and change name of mday field to match below. + $wrongfields = $calendartype->timestamp_to_date_array(time()); + $current = array('day' => $wrongfields['mday'], + 'month' => $wrongfields['mon'], 'year' => $wrongfields['year'], + 'hour' => 0, 'minute' => 0); + + // Time part is handled the same everywhere. + $hours = array(); + for ($i = 0; $i <= 23; $i++) { + $hours[$i] = sprintf("%02d", $i); + } + $minutes = array(); + for ($i = 0; $i < 60; $i += 5) { + $minutes[$i] = sprintf("%02d", $i); + } + + // List date fields. + $fields = $calendartype->get_date_order( + $calendartype->get_min_year(), $calendartype->get_max_year()); + + // Add time fields - in RTL mode these are switched. + $fields['split'] = '/'; + if (right_to_left()) { + $fields['minute'] = $minutes; + $fields['colon'] = ':'; + $fields['hour'] = $hours; + } else { + $fields['hour'] = $hours; + $fields['colon'] = ':'; + $fields['minute'] = $minutes; + } + + // Output all date fields. + $html = ''; + foreach ($fields as $field => $options) { + if ($options === '/') { + $html = rtrim($html); + + // In Gregorian calendar mode only, we support a date selector popup, reusing + // code from form to ensure consistency. + if ($calendartype->get_name() === 'gregorian' && self::DATE_SELECTOR_SUPPORTED) { + $image = $OUTPUT->pix_icon('i/calendar', get_string('calendar', 'calendar'), 'moodle'); + $html .= ' ' . \html_writer::link('#', $image, array('name' => 'x[calendar]')); + form_init_date_js(); + } + + $html .= ' '; + continue; + } + if ($options === ':') { + $html .= ': '; + continue; + } + $html .= \html_writer::start_tag('label'); + $html .= \html_writer::span(get_string($field) . ' ', 'accesshide'); + // NOTE: The fields need to have these weird names in order that they + // match the standard Moodle form control, otherwise the date selector + // won't find them. + $html .= \html_writer::start_tag('select', array('name' => 'x[' . $field . ']')); + foreach ($options as $key => $value) { + $params = array('value' => $key); + if ($current[$field] == $key) { + $params['selected'] = 'selected'; + } + $html .= \html_writer::tag('option', s($value), $params); + } + $html .= \html_writer::end_tag('select'); + $html .= \html_writer::end_tag('label'); + $html .= ' '; + } + $html = rtrim($html) . ''; + + // Also get the time that corresponds to this default date. + $time = self::get_time_from_fields($current['year'], $current['month'], + $current['day'], $current['hour'], $current['minute']); + + return array($html, $time); + } +} diff --git a/availability/condition/date/lang/en/availability_date.php b/availability/condition/date/lang/en/availability_date.php new file mode 100644 index 00000000000..078961e746d --- /dev/null +++ b/availability/condition/date/lang/en/availability_date.php @@ -0,0 +1,40 @@ +. + +/** + * Language strings. + * + * @package availability_date + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['ajaxerror'] = 'Error contacting server to convert times'; +$string['direction_before'] = 'Date'; +$string['direction_from'] = 'from'; +$string['direction_label'] = 'Direction'; +$string['direction_until'] = 'until'; +$string['description'] = 'Prevent access until (or from) a specified date and time.'; +$string['full_from'] = 'It is after {$a}'; +$string['full_from_date'] = 'It is on or after {$a}'; +$string['full_until'] = 'It is before {$a}'; +$string['full_until_date'] = 'It is before end of {$a}'; +$string['pluginname'] = 'Restriction by date'; +$string['short_from'] = 'Available from {$a}'; +$string['short_from_date'] = 'Available from {$a}'; +$string['short_until'] = 'Available until {$a}'; +$string['short_until_date'] = 'Available until end of {$a}'; +$string['title'] = 'Date'; diff --git a/availability/condition/date/tests/behat/availability_date.feature b/availability/condition/date/tests/behat/availability_date.feature new file mode 100644 index 00000000000..ff73589bd17 --- /dev/null +++ b/availability/condition/date/tests/behat/availability_date.feature @@ -0,0 +1,65 @@ +@availability @availability_date +Feature: availability_date + In order to control student access to activities + As a teacher + I need to set date conditions which prevent student access + + Background: + Given the following "courses" exist: + | fullname | shortname | format | enablecompletion | + | Course 1 | C1 | topics | 1 | + And the following "users" exist: + | username | + | teacher1 | + | student1 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And I log in as "admin" + And I set the following administration settings values: + | Enable conditional access | 1 | + And I log out + + @javascript + Scenario: Test condition + # Basic setup. + Given I log in as "teacher1" + And I follow "Course 1" + And I turn editing mode on + + # Add a Page with a date condition that does match (from the past). + And I add a "Page" to section "1" + And I set the following fields to these values: + | Name | Page 1 | + | Description | Test | + | Page content | Test | + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I click on ".availability-item .availability-eye img" "css_element" + And I set the field "year" to "2013" + And I press "Save and return to course" + + # Add a Page with a date condition that doesn't match (until the past). + And I add a "Page" to section "2" + And I set the following fields to these values: + | Name | Page 2 | + | Description | Test | + | Page content | Test | + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I click on ".availability-item .availability-eye img" "css_element" + And I set the field "Direction" to "until" + And I set the field "year" to "2013" + And I press "Save and return to course" + + # Log back in as student. + When I log out + And I log in as "student1" + And I follow "Course 1" + + # Page 1 should appear, but page 2 does not. + Then I should see "Page 1" in the "region-main" "region" + And I should not see "Page 2" in the "region-main" "region" diff --git a/availability/condition/date/tests/condition_test.php b/availability/condition/date/tests/condition_test.php new file mode 100644 index 00000000000..425f1458d1c --- /dev/null +++ b/availability/condition/date/tests/condition_test.php @@ -0,0 +1,236 @@ +. + +/** + * Unit tests for the date condition. + * + * @package availability_date + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +use availability_date\condition; + +/** + * Unit tests for the date condition. + * + * @package availability_date + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class availability_date_condition_testcase extends advanced_testcase { + /** + * 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'); + } + + /** + * Tests constructing and using date condition as part of tree. + */ + public function test_in_tree() { + global $SITE, $USER; + $this->resetAfterTest(); + $this->setAdminUser(); + + // Set server timezone for test. (Important as otherwise the timezone + // could be anything - this is modified by other unit tests, too.) + date_default_timezone_set('UTC'); + + // SEt user to GMT+5. + $USER->timezone = 5; + + // Construct tree with date condition. + $time = strtotime('2014-02-18 14:20:00 GMT'); + $structure = (object)array('op' => '|', 'show' => true, 'c' => array( + (object)array('type' => 'date', 'd' => '>=', 't' => $time))); + $tree = new \core_availability\tree($structure); + $info = new \core_availability\mock_info(); + + // Check if available (when not available). + condition::set_current_time_for_test($time - 1); + $information = ''; + $result = $tree->check_available(false, $info, true, $USER->id); + $this->assertFalse($result->is_available()); + $information = $tree->get_result_information($info, $result); + + // Note: PM is normally upper-case, but an issue with PHP on Mac means + // that on that platform, it is reported lower-case. + $this->assertRegExp('~from.*18 February 2014, 7:20 (PM|pm)~', $information); + + // Check if available (when available). + condition::set_current_time_for_test($time); + $result = $tree->check_available(false, $info, true, $USER->id); + $this->assertTrue($result->is_available()); + $information = $tree->get_result_information($info, $result); + $this->assertEquals('', $information); + } + + /** + * Tests the constructor including error conditions. Also tests the + * string conversion feature (intended for debugging only). + */ + public function test_constructor() { + // No parameters. + $structure = (object)array(); + try { + $date = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing or invalid ->d', $e->getMessage()); + } + + // Invalid ->d. + $structure->d = 'woo hah!!'; + try { + $date = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing or invalid ->d', $e->getMessage()); + } + + // Missing ->t. + $structure->d = '>='; + try { + $date = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing or invalid ->t', $e->getMessage()); + } + + // Invalid ->t. + $structure->t = 'got you all in check'; + try { + $date = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing or invalid ->t', $e->getMessage()); + } + + // Valid conditions of both types. + $structure = (object)array('d' => '>=', 't' => strtotime('2014-02-18 14:43:17 GMT')); + $date = new condition($structure); + $this->assertEquals('{date:>= 2014-02-18 14:43:17}', (string)$date); + $structure->d = '<'; + $date = new condition($structure); + $this->assertEquals('{date:< 2014-02-18 14:43:17}', (string)$date); + } + + /** + * Tests the save() function. + */ + public function test_save() { + $structure = (object)array('d' => '>=', 't' => 12345); + $cond = new condition($structure); + $structure->type = 'date'; + $this->assertEquals($structure, $cond->save()); + } + + /** + * Tests the is_available() and is_available_to_all() functions. + */ + public function test_is_available() { + global $SITE, $USER; + + $time = strtotime('2014-02-18 14:50:10 GMT'); + $info = new \core_availability\mock_info(); + + // Test with >=. + $date = new condition((object)array('d' => '>=', 't' => $time)); + condition::set_current_time_for_test($time - 1); + $this->assertFalse($date->is_available(false, $info, true, $USER->id)); + condition::set_current_time_for_test($time); + $this->assertTrue($date->is_available(false, $info, true, $USER->id)); + + // Test with <. + $date = new condition((object)array('d' => '<', 't' => $time)); + condition::set_current_time_for_test($time); + $this->assertFalse($date->is_available(false, $info, true, $USER->id)); + condition::set_current_time_for_test($time - 1); + $this->assertTrue($date->is_available(false, $info, true, $USER->id)); + + // Repeat this test with is_available_to_all() - it should be the same. + $date = new condition((object)array('d' => '<', 't' => $time)); + condition::set_current_time_for_test($time); + $this->assertFalse($date->is_available_for_all(false)); + condition::set_current_time_for_test($time - 1); + $this->assertTrue($date->is_available_for_all(false)); + } + + /** + * Tests the get_description and get_standalone_description functions. + */ + public function test_get_description() { + global $SITE; + + $modinfo = get_fast_modinfo($SITE); + $info = new \core_availability\mock_info(); + $time = strtotime('2014-02-18 14:55:01 GMT'); + + // Test with >=. + $date = new condition((object)array('d' => '>=', 't' => $time)); + $information = $date->get_description(true, false, $info); + $this->assertRegExp('~after.*18 February 2014, 2:55 (PM|pm)~', $information); + $information = $date->get_description(true, true, $info); + $this->assertRegExp('~before.*18 February 2014, 2:55 (PM|pm)~', $information); + $information = $date->get_standalone_description(true, false, $info); + $this->assertRegExp('~from.*18 February 2014, 2:55 (PM|pm)~', $information); + $information = $date->get_standalone_description(true, true, $info); + $this->assertRegExp('~until.*18 February 2014, 2:55 (PM|pm)~', $information); + + // Test with <. + $date = new condition((object)array('d' => '<', 't' => $time)); + $information = $date->get_description(true, false, $info); + $this->assertRegExp('~before.*18 February 2014, 2:55 (PM|pm)~', $information); + $information = $date->get_description(true, true, $info); + $this->assertRegExp('~after.*18 February 2014, 2:55 (PM|pm)~', $information); + $information = $date->get_standalone_description(true, false, $info); + $this->assertRegExp('~until.*18 February 2014, 2:55 (PM|pm)~', $information); + $information = $date->get_standalone_description(true, true, $info); + $this->assertRegExp('~from.*18 February 2014, 2:55 (PM|pm)~', $information); + + // Test special case for dates that are midnight. + $date = new condition((object)array('d' => '>=', + 't' => strtotime('2014-03-05 00:00 GMT'))); + $information = $date->get_description(true, false, $info); + $this->assertRegExp('~on or after.*5 March 2014([^0-9]*)$~', $information); + $information = $date->get_description(true, true, $info); + $this->assertRegExp('~before.*end of.*4 March 2014([^0-9]*)$~', $information); + $information = $date->get_standalone_description(true, false, $info); + $this->assertRegExp('~from.*5 March 2014([^0-9]*)$~', $information); + $information = $date->get_standalone_description(true, true, $info); + $this->assertRegExp('~until end of.*4 March 2014([^0-9]*)$~', $information); + + // In the 'until' case for midnight, it shows the previous day. (I.e. + // if the date is 5 March 00:00, then we show it as available until 4 + // March, implying 'the end of'.) + $date = new condition((object)array('d' => '<', + 't' => strtotime('2014-03-05 00:00 GMT'))); + $information = $date->get_description(true, false, $info); + $this->assertRegExp('~before end of.*4 March 2014([^0-9]*)$~', $information); + $information = $date->get_description(true, true, $info); + $this->assertRegExp('~on or after.*5 March 2014([^0-9]*)$~', $information); + $information = $date->get_standalone_description(true, false, $info); + $this->assertRegExp('~until end of.*4 March 2014([^0-9]*)$~', $information); + $information = $date->get_standalone_description(true, true, $info); + $this->assertRegExp('~from.*5 March 2014([^0-9]*)$~', $information); + } +} diff --git a/availability/condition/date/version.php b/availability/condition/date/version.php new file mode 100644 index 00000000000..8b3120b2c92 --- /dev/null +++ b/availability/condition/date/version.php @@ -0,0 +1,29 @@ +. + +/** + * Version info. + * + * @package availability_date + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->version = 2014021800; +$plugin->requires = 2014040401; +$plugin->component = 'availability_date'; diff --git a/availability/condition/date/yui/src/form/build.json b/availability/condition/date/yui/src/form/build.json new file mode 100644 index 00000000000..c1df64895d9 --- /dev/null +++ b/availability/condition/date/yui/src/form/build.json @@ -0,0 +1,10 @@ +{ + "name": "moodle-availability_date-form", + "builds": { + "moodle-availability_date-form": { + "jsfiles": [ + "form.js" + ] + } + } +} diff --git a/availability/condition/date/yui/src/form/js/form.js b/availability/condition/date/yui/src/form/js/form.js new file mode 100644 index 00000000000..9eff4e77c38 --- /dev/null +++ b/availability/condition/date/yui/src/form/js/form.js @@ -0,0 +1,141 @@ +/** + * JavaScript for form editing date conditions. + * + * @module moodle-availability_date-form + */ +M.availability_date = M.availability_date || {}; + +/** + * @class M.availability_date.form + * @extends M.core_availability.plugin + */ +M.availability_date.form = Y.Object(M.core_availability.plugin); + +/** + * Initialises this plugin. + * + * Because the date fields are complex depending on Moodle calendar settings, + * we create the HTML for these fields in PHP and pass it to this method. + * + * @method initInner + * @param {String} html HTML to use for date fields + * @param {Number} defaultTime Time value that corresponds to initial fields + */ +M.availability_date.form.initInner = function(html, defaultTime) { + this.html = html; + this.defaultTime = defaultTime; +}; + +M.availability_date.form.getNode = function(json) { + var strings = M.str.availability_date; + var html = strings.direction_before + ' ' + + ' ' + this.html; + var node = Y.Node.create('' + html + ''); + + // Set initial value if non-default. + if (json.t !== undefined) { + node.setData('time', json.t); + // Disable everything. + node.all('select:not([name=direction])').each(function(select) { + select.set('disabled', true); + }); + + var url = M.cfg.wwwroot + '/availability/condition/date/ajax.php?action=fromtime' + + '&time=' + json.t; + Y.io(url, { on : { + success : function(id, response) { + var fields = Y.JSON.parse(response.responseText); + for (var field in fields) { + var select = node.one('select[name=x\\[' + field + '\\]]'); + select.set('value', fields[field]); + select.set('disabled', false); + } + }, + failure : function() { + window.alert(M.str.availability_date.ajaxerror); + } + }}); + } else { + // Set default time that corresponds to the HTML selectors. + node.setData('time', this.defaultTime); + } + if (json.d !== undefined) { + node.one('select[name=direction]').set('value', json.d); + } + + // Add event handlers (first time only). + if (!M.availability_date.form.addedEvents) { + M.availability_date.form.addedEvents = true; + + var root = Y.one('#fitem_id_availabilityconditionsjson'); + root.delegate('change', function() { + // For the direction, just update the form fields. + M.core_availability.form.update(); + }, '.availability_date select[name=direction]'); + + root.delegate('change', function() { + // Update time using AJAX call from root node. + M.availability_date.form.updateTime(this.ancestor('span.availability_date')); + }, '.availability_date select:not([name=direction])'); + } + + if (node.one('a[href=#]')) { + // Add the date selector magic. + M.form.dateselector.init_single_date_selector(node); + + // This special handler detects when the date selector changes the year. + var yearSelect = node.one('select[name=x\\[year\\]]'); + var oldSet = yearSelect.set; + yearSelect.set = function(name, value) { + oldSet.call(yearSelect, name, value); + if (name === 'selectedIndex') { + // Do this after timeout or the other fields haven't been set yet. + setTimeout(function() { + M.availability_date.form.updateTime(node); + }, 0); + } + }; + } + + return node; +}; + +/** + * Updates time from AJAX. Whenever the field values change, we recompute the + * actual time via an AJAX request to Moodle. + * + * This will set the 'time' data on the node and then update the form, once it + * gets an AJAX response. + * + * @method updateTime + * @param {Y.Node} component Node for plugin controls + */ +M.availability_date.form.updateTime = function(node) { + // After a change to the date/time we need to recompute the + // actual time using AJAX because it depends on the user's + // time zone and calendar options. + var url = M.cfg.wwwroot + '/availability/condition/date/ajax.php?action=totime' + + '&year=' + node.one('select[name=x\\[year\\]]').get('value') + + '&month=' + node.one('select[name=x\\[month\\]]').get('value') + + '&day=' + node.one('select[name=x\\[day\\]]').get('value') + + '&hour=' + node.one('select[name=x\\[hour\\]]').get('value') + + '&minute=' + node.one('select[name=x\\[minute\\]]').get('value'); + Y.io(url, { on : { + success : function(id, response) { + node.setData('time', response.responseText); + M.core_availability.form.update(); + }, + failure : function() { + window.alert(M.str.availability_date.ajaxerror); + } + }}); +}; + +M.availability_date.form.fillValue = function(value, node) { + value.d = node.one('select[name=direction]').get('value'); + value.t = parseInt(node.getData('time'), 10); +}; diff --git a/availability/condition/date/yui/src/form/meta/form.json b/availability/condition/date/yui/src/form/meta/form.json new file mode 100644 index 00000000000..195d1f064f3 --- /dev/null +++ b/availability/condition/date/yui/src/form/meta/form.json @@ -0,0 +1,11 @@ +{ + "moodle-availability_date-form": { + "requires": [ + "base", + "node", + "event", + "io", + "moodle-core_availability-form" + ] + } +} diff --git a/availability/condition/grade/classes/callbacks.php b/availability/condition/grade/classes/callbacks.php new file mode 100644 index 00000000000..c6383f818a1 --- /dev/null +++ b/availability/condition/grade/classes/callbacks.php @@ -0,0 +1,57 @@ +. + +/** + * Observer handling events. + * + * @package availability_grade + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace availability_grade; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Callbacks handling grade changes (to clear cache). + * + * This ought to use the hooks system, but it doesn't exist - calls are + * hard-coded. (The new event system is not suitable for this type of use.) + * + * @package availability_grade + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class callbacks { + /** + * A user grade has been updated in gradebook. + * + * @param int $userid User ID + */ + public static function grade_changed($userid) { + \cache::make('availability_grade', 'scores')->delete($userid); + } + + /** + * A grade item has been updated in gradebook. + * + * @param int $courseid Course id + */ + public static function grade_item_changed($courseid) { + \cache::make('availability_grade', 'items')->delete($courseid); + } +} diff --git a/availability/condition/grade/classes/condition.php b/availability/condition/grade/classes/condition.php new file mode 100644 index 00000000000..887fdbc191e --- /dev/null +++ b/availability/condition/grade/classes/condition.php @@ -0,0 +1,274 @@ +. + +/** + * Condition on grades of current user. + * + * @package availability_grade + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace availability_grade; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Condition on grades of current user. + * + * @package availability_grade + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class condition extends \core_availability\condition { + /** @var int Grade item id */ + private $gradeitemid; + + /** @var float|null Min grade (must be >= this) or null if none */ + private $min; + + /** @var float|null Max grade (must be < this) or null if none */ + private $max; + + /** + * Constructor. + * + * @param stdClass $structure Data structure from JSON decode + * @throws coding_exception If invalid data structure. + */ + public function __construct($structure) { + // Get grade item id. + if (isset($structure->id) && is_int($structure->id)) { + $this->gradeitemid = $structure->id; + } else { + throw new \coding_exception('Missing or invalid ->id for grade condition'); + } + + // Get min and max. + if (!property_exists($structure, 'min')) { + $this->min = null; + } else if (is_float($structure->min) || is_int($structure->min)) { + $this->min = $structure->min; + } else { + throw new \coding_exception('Missing or invalid ->min for grade condition'); + } + if (!property_exists($structure, 'max')) { + $this->max = null; + } else if (is_float($structure->max) || is_int($structure->max)) { + $this->max = $structure->max; + } else { + throw new \coding_exception('Missing or invalid ->max for grade condition'); + } + } + + public function save() { + $result = (object)array('type' => 'grade', 'id' => $this->gradeitemid); + if (!is_null($this->min)) { + $result->min = $this->min; + } + if (!is_null($this->max)) { + $result->max = $this->max; + } + return $result; + } + + public function is_available($not, \core_availability\info $info, $grabthelot, $userid) { + $course = $info->get_course(); + $score = $this->get_cached_grade_score($this->gradeitemid, $course->id, $grabthelot, $userid); + $allow = $score !== false && + (is_null($this->min) || $score >= $this->min) && + (is_null($this->max) || $score < $this->max); + if ($not) { + $allow = !$allow; + } + + return $allow; + } + + public function get_description($full, $not, \core_availability\info $info) { + $course = $info->get_course(); + // String depends on type of requirement. We are coy about + // the actual numbers, in case grades aren't released to + // students. + if (is_null($this->min) && is_null($this->max)) { + $string = 'any'; + } else if (is_null($this->max)) { + $string = 'min'; + } else if (is_null($this->min)) { + $string = 'max'; + } else { + $string = 'range'; + } + if ($not) { + // The specific strings don't make as much sense with 'not'. + if ($string === 'any') { + $string = 'notany'; + } else { + $string = 'notgeneral'; + } + } + $name = self::get_cached_grade_name($course->id, $this->gradeitemid); + return get_string('requires_' . $string, 'availability_grade', $name); + } + + protected function get_debug_string() { + $out = '#' . $this->gradeitemid; + if (!is_null($this->min)) { + $out .= ' >= ' . sprintf('%.5f', $this->min); + } + if (!is_null($this->max)) { + if (!is_null($this->min)) { + $out .= ','; + } + $out .= ' < ' . sprintf('%.5f', $this->max); + } + return $out; + } + + /** + * Obtains the name of a grade item, also checking that it exists. Uses a + * cache. The name returned is suitable for display. + * + * @param int $courseid Course id + * @param int $gradeitemid Grade item id + * @return string Grade name or empty string if no grade with that id + */ + private static function get_cached_grade_name($courseid, $gradeitemid) { + global $DB, $CFG; + require_once($CFG->libdir . '/gradelib.php'); + + // Get all grade item names from cache, or using db query. + $cache = \cache::make('availability_grade', 'items'); + if (($cacheditems = $cache->get($courseid)) === false) { + // We cache the whole items table not the name; the format_string + // call for the name might depend on current user (e.g. multilang) + // and this is a shared cache. + $cacheditems = $DB->get_records('grade_items', array('courseid' => $courseid)); + $cache->set($courseid, $cacheditems); + } + + // Return name from cached item or a lang string. + if (!array_key_exists($gradeitemid, $cacheditems)) { + return get_string('missing', 'availability_grade'); + } + $gradeitemobj = $cacheditems[$gradeitemid]; + $item = new \grade_item; + \grade_object::set_properties($item, $gradeitemobj); + return $item->get_name(); + } + + /** + * Obtains a grade score. Note that this score should not be displayed to + * the user, because gradebook rules might prohibit that. It may be a + * non-final score subject to adjustment later. + * + * @param int $gradeitemid Grade item ID we're interested in + * @param int $courseid Course id + * @param bool $grabthelot If true, grabs all scores for current user on + * this course, so that later ones come from cache + * @param int $userid Set if requesting grade for a different user (does + * not use cache) + * @return float Grade score as a percentage in range 0-100 (e.g. 100.0 + * or 37.21), or false if user does not have a grade yet + */ + protected static function get_cached_grade_score($gradeitemid, $courseid, + $grabthelot=false, $userid=0) { + global $USER, $DB; + if (!$userid) { + $userid = $USER->id; + } + $cache = \cache::make('availability_grade', 'scores'); + if (($cachedgrades = $cache->get($userid)) === false) { + $cachedgrades = array(); + } + if (!array_key_exists($gradeitemid, $cachedgrades)) { + if ($grabthelot) { + // Get all grades for the current course. + $rs = $DB->get_recordset_sql(' + SELECT + gi.id,gg.finalgrade,gg.rawgrademin,gg.rawgrademax + FROM + {grade_items} gi + LEFT JOIN {grade_grades} gg ON gi.id=gg.itemid AND gg.userid=? + WHERE + gi.courseid = ?', array($userid, $courseid)); + foreach ($rs as $record) { + if (is_null($record->finalgrade)) { + // No grade = false. + $cachedgrades[$record->id] = false; + } else { + // Otherwise convert grade to percentage. + $cachedgrades[$record->id] = + (($record->finalgrade - $record->rawgrademin) * 100) / + ($record->rawgrademax - $record->rawgrademin); + } + } + $rs->close(); + // And if it's still not set, well it doesn't exist (eg + // maybe the user set it as a condition, then deleted the + // grade item) so we call it false. + if (!array_key_exists($gradeitemid, $cachedgrades)) { + $cachedgrades[$gradeitemid] = false; + } + } else { + // Just get current grade. + $record = $DB->get_record('grade_grades', array( + 'userid' => $userid, 'itemid' => $gradeitemid)); + if ($record && !is_null($record->finalgrade)) { + $score = (($record->finalgrade - $record->rawgrademin) * 100) / + ($record->rawgrademax - $record->rawgrademin); + } else { + // Treat the case where row exists but is null, same as + // case where row doesn't exist. + $score = false; + } + $cachedgrades[$gradeitemid] = $score; + } + $cache->set($userid, $cachedgrades); + } + return $cachedgrades[$gradeitemid]; + } + + public function update_after_restore($restoreid, $courseid, \base_logger $logger, $name) { + global $DB; + $rec = \restore_dbops::get_backup_ids_record($restoreid, 'grade_item', $this->gradeitemid); + 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('grade_items', + array('id' => $this->gradeitemid, 'courseid' => $courseid))) { + return false; + } + // Otherwise it's a warning. + $this->gradeitemid = 0; + $logger->process('Restored item (' . $name . + ') has availability condition on grade that was not restored', + \backup::LOG_WARNING); + } else { + $this->gradeitemid = (int)$rec->newitemid; + } + return true; + } + + public function update_dependency_id($table, $oldid, $newid) { + if ($table === 'grade_items' && (int)$this->gradeitemid === (int)$oldid) { + $this->gradeitemid = $newid; + return true; + } else { + return false; + } + } +} diff --git a/availability/condition/grade/classes/frontend.php b/availability/condition/grade/classes/frontend.php new file mode 100644 index 00000000000..32b9c2f83a6 --- /dev/null +++ b/availability/condition/grade/classes/frontend.php @@ -0,0 +1,69 @@ +. + +/** + * Front-end class. + * + * @package availability_grade + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace availability_grade; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Front-end class. + * + * @package availability_grade + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class frontend extends \core_availability\frontend { + protected function get_javascript_strings() { + return array('option_min', 'option_max', 'label_min', 'label_max'); + } + + protected function get_javascript_init_params($course, \cm_info $cm = null, + \section_info $section = null) { + global $DB, $CFG; + require_once($CFG->libdir . '/gradelib.php'); + + // Get grades as basic associative array. + $gradeoptions = array(); + $items = \grade_item::fetch_all(array('courseid' => $course->id)); + // For some reason the fetch_all things return null if none. + $items = $items ? $items : array(); + foreach ($items as $id => $item) { + // Do not include grades for current item. + if ($cm && $cm->instance == $item->iteminstance + && $cm->modname == $item->itemmodule + && $item->itemtype == 'mod') { + continue; + } + $gradeoptions[$id] = $item->get_name(); + } + \core_collator::asort($gradeoptions); + + // Change to JS array format and return. + $jsarray = array(); + foreach ($gradeoptions as $id => $name) { + $jsarray[] = (object)array('id' => $id, 'name' => $name); + } + return array($jsarray); + } +} diff --git a/availability/condition/grade/db/caches.php b/availability/condition/grade/db/caches.php new file mode 100644 index 00000000000..b7135d6f198 --- /dev/null +++ b/availability/condition/grade/db/caches.php @@ -0,0 +1,40 @@ +. + +/** + * Cache definitions. + * + * @package availability_grade + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$definitions = array( + // Used to cache user grades for conditional availability purposes. + 'scores' => array( + 'mode' => cache_store::MODE_APPLICATION, + 'staticacceleration' => true, + 'staticaccelerationsize' => 2, // Should not be required for more than one user at a time. + 'ttl' => 3600, + ), + // Used to cache course grade items for conditional availability purposes. + 'items' => array( + 'mode' => cache_store::MODE_APPLICATION, + 'staticacceleration' => true, + 'staticaccelerationsize' => 2, // Should not be required for more than one course at a time. + 'ttl' => 3600, + ), +); diff --git a/availability/condition/grade/lang/en/availability_grade.php b/availability/condition/grade/lang/en/availability_grade.php new file mode 100644 index 00000000000..268746074ed --- /dev/null +++ b/availability/condition/grade/lang/en/availability_grade.php @@ -0,0 +1,43 @@ +. + +/** + * Language strings. + * + * @package availability_grade + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['cachedef_items'] = 'Grade items cached for evaluating conditional availability'; +$string['cachedef_scores'] = 'User grades cached for evaluating conditional availability'; +$string['description'] = 'Require students to achieve a specified grade.'; +$string['error_backwardrange'] = 'When specifying a grade range, the minimum must be lower than the maximum.'; +$string['error_invalidnumber'] = 'Grade ranges must be specified with valid percentages.'; +$string['error_selectgradeid'] = 'You must select a grade item for the grade condition.'; +$string['label_min'] = 'Minimum grade percentage (inclusive)'; +$string['label_max'] = 'Maximum grade percentage (exclusive)'; +$string['option_min'] = 'must be ≥'; +$string['option_max'] = 'must be <'; +$string['pluginname'] = 'Restriction by grades'; +$string['requires_any'] = 'You have a grade in {$a}'; +$string['requires_max'] = 'You get an appropriate score in {$a}'; +$string['requires_min'] = 'You achieve a required score in {$a}'; +$string['requires_notany'] = 'You do not have a grade in {$a}'; +$string['requires_notgeneral'] = 'You do not get certain scores in {$a}'; +$string['requires_range'] = 'You get a particular score in {$a}'; +$string['missing'] = '(missing activity)'; +$string['title'] = 'Grade'; diff --git a/availability/condition/grade/styles.css b/availability/condition/grade/styles.css new file mode 100644 index 00000000000..5112690d8bf --- /dev/null +++ b/availability/condition/grade/styles.css @@ -0,0 +1,3 @@ +#fitem_id_availabilityconditionsjson .availability_grade input[type=text] { + width: 3em; +} diff --git a/availability/condition/grade/tests/behat/availability_grade.feature b/availability/condition/grade/tests/behat/availability_grade.feature new file mode 100644 index 00000000000..47baf048705 --- /dev/null +++ b/availability/condition/grade/tests/behat/availability_grade.feature @@ -0,0 +1,119 @@ +@availability @availability_grade +Feature: availability_grade + In order to control student access to activities + As a teacher + I need to set date conditions which prevent student access + + Background: + Given the following "courses" exist: + | fullname | shortname | format | enablecompletion | + | Course 1 | C1 | topics | 1 | + And the following "users" exist: + | username | email | + | teacher1 | t@example.org | + | student1 | s@example.org | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And I log in as "admin" + And I set the following administration settings values: + | Enable conditional access | 1 | + And I log out + + @javascript + Scenario: Test condition + # Basic setup. + Given I log in as "teacher1" + And I follow "Course 1" + And I turn editing mode on + + # Add an assignment. + And I add a "Assignment" to section "1" and I fill the form with: + | Assignment name | A1 | + | Description | x | + | Online text | 1 | + + # Add a Page with a grade condition for 'any grade'. + And I add a "Page" to section "2" + And I set the following fields to these values: + | Name | P2 | + | Description | x | + | Page content | x | + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Grade" "button" in the "Add restriction..." "dialogue" + And I click on ".availability-item .availability-eye img" "css_element" + And I set the field "Grade" to "A1" + And I press "Save and return to course" + + # Add a Page with a grade condition for 50%. + And I add a "Page" to section "3" + And I set the following fields to these values: + | Name | P3 | + | Description | x | + | Page content | x | + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Grade" "button" in the "Add restriction..." "dialogue" + And I click on ".availability-item .availability-eye img" "css_element" + And I set the field "Grade" to "A1" + And I click on "min" "checkbox" in the ".availability-item" "css_element" + And I set the field "Minimum grade percentage (inclusive)" to "50" + And I press "Save and return to course" + + # Add a Page with a grade condition for 10%. + And I add a "Page" to section "4" + And I set the following fields to these values: + | Name | P4 | + | Description | x | + | Page content | x | + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Grade" "button" in the "Add restriction..." "dialogue" + And I click on ".availability-item .availability-eye img" "css_element" + And I set the field "Grade" to "A1" + And I click on "min" "checkbox" in the ".availability-item" "css_element" + And I set the field "Minimum grade percentage (inclusive)" to "10" + And I press "Save and return to course" + + # Log in as student without a grade yet. + When I log out + And I log in as "student1" + And I follow "Course 1" + + # Do the assignment. + And I follow "A1" + And I click on "Add submission" "button" + And I set the field "Online text" to "Q" + And I click on "Save changes" "button" + And I follow "C1" + + # None of the pages should appear (check assignment though). + Then I should not see "P2" in the "region-main" "region" + And I should not see "P3" in the "region-main" "region" + And I should not see "P4" in the "region-main" "region" + And I should see "A1" in the "region-main" "region" + + # Log back in as teacher. + When I log out + And I log in as "teacher1" + And I follow "Course 1" + + # Give the assignment 40%. + And I follow "A1" + And I follow "View/grade all submissions" + # Pick the grade link in the row that has s@example.org in it. + And I click on "//a[contains(@href, 'action=grade') and ancestor::tr/td[normalize-space(.) = 's@example.org']]/img" "xpath_element" + And I set the field "Grade out of 100" to "40" + And I click on "Save changes" "button" + + # Log back in as student. + And I log out + And I log in as "student1" + And I follow "Course 1" + + # Check pages are visible. + Then I should see "P2" in the "region-main" "region" + And I should see "P4" in the "region-main" "region" + And I should not see "P3" in the "region-main" "region" diff --git a/availability/condition/grade/tests/condition_test.php b/availability/condition/grade/tests/condition_test.php new file mode 100644 index 00000000000..8f2eb02d0c0 --- /dev/null +++ b/availability/condition/grade/tests/condition_test.php @@ -0,0 +1,247 @@ +. + +/** + * Unit tests for the grade condition. + * + * @package availability_grade + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +use availability_grade\condition; + +/** + * Unit tests for the grade condition. + * + * @package availability_grade + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class availability_grade_condition_testcase extends advanced_testcase { + /** + * Tests constructing and using grade condition. + */ + public function test_usage() { + global $USER, $CFG; + require_once($CFG->dirroot . '/mod/assign/locallib.php'); + + $this->resetAfterTest(); + $CFG->enableavailability = true; + + // Make a test course and user. + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($user->id, $course->id); + + // Make assign module. + $assignrow = $this->getDataGenerator()->create_module('assign', array( + 'course' => $course->id, 'name' => 'Test!')); + $assign = new assign(context_module::instance($assignrow->cmid), false, false); + $modinfo = get_fast_modinfo($course); + $cm = $modinfo->get_cm($assignrow->cmid); + + $info = new \core_availability\info_module($cm); + + // Get the actual grade item. + $item = $assign->get_grade_item(); + + // Construct tree with grade condition (any grade, specified item). + $structure = (object)array('type' => 'grade', 'id' => (int)$item->id); + $cond = new condition($structure); + + // Check if available (not available). + $this->assertFalse($cond->is_available(false, $info, true, $user->id)); + $information = $cond->get_description(false, false, $info); + $this->assertRegExp('~have a grade.*Test!~', $information); + $this->assertTrue($cond->is_available(true, $info, true, $user->id)); + + // Add grade and check available. + self::set_grade($assignrow, $user->id, 37.2); + $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); + $this->assertRegExp('~do not have a grade.*Test!~', $information); + + // Construct directly and test remaining conditions; first, min grade (fail). + self::set_grade($assignrow, $user->id, 29.99999); + $structure->min = 30.0; + $cond = new condition($structure); + $this->assertFalse($cond->is_available(false, $info, true, $user->id)); + $information = $cond->get_description(false, false, $info); + $this->assertRegExp('~achieve a required score.*Test!~', $information); + $this->assertTrue($cond->is_available(true, $info, true, $user->id)); + + // Min grade (success). + self::set_grade($assignrow, $user->id, 30); + $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); + $this->assertRegExp('~do not get certain scores.*Test!~', $information); + + // Max grade (fail). + unset($structure->min); + $structure->max = 30.0; + $cond = new condition($structure); + $this->assertFalse($cond->is_available(false, $info, true, $user->id)); + $information = $cond->get_description(false, false, $info); + $this->assertRegExp('~get an appropriate score.*Test!~', $information); + $this->assertTrue($cond->is_available(true, $info, true, $user->id)); + + // Max grade (success). + self::set_grade($assignrow, $user->id, 29.99999); + $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); + $this->assertRegExp('~do not get certain scores.*Test!~', $information); + + // Max and min (fail). + $structure->min = 30.0; + $structure->max = 34.12345; + $cond = new condition($structure); + $this->assertFalse($cond->is_available(false, $info, true, $user->id)); + $information = $cond->get_description(false, false, $info); + $this->assertRegExp('~get a particular score.*Test!~', $information); + $this->assertTrue($cond->is_available(true, $info, true, $user->id)); + + // Still fail (other end). + self::set_grade($assignrow, $user->id, 34.12345); + $this->assertFalse($cond->is_available(false, $info, true, $user->id)); + + // Success (top end). + self::set_grade($assignrow, $user->id, 34.12344); + $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); + $this->assertRegExp('~do not get certain scores.*Test!~', $information); + + // Success (bottom end). + self::set_grade($assignrow, $user->id, 30.0); + $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); + $this->assertRegExp('~do not get certain scores.*Test!~', $information); + } + + /** + * Tests the constructor including error conditions. Also tests the + * string conversion feature (intended for debugging only). + */ + public function test_constructor() { + // No parameters. + $structure = new stdClass(); + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing or invalid ->id', $e->getMessage()); + } + + // Invalid id (not int). + $structure->id = 'bourne'; + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing or invalid ->id', $e->getMessage()); + } + + // Invalid min (not number). + $structure->id = 42; + $structure->min = 'ute'; + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing or invalid ->min', $e->getMessage()); + } + + // Invalid max (not number). + $structure->min = 3.89; + $structure->max = '9000'; + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing or invalid ->max', $e->getMessage()); + } + + // All valid. + $structure->max = 4.0; + $cond = new condition($structure); + $this->assertEquals('{grade:#42 >= 3.89000, < 4.00000}', (string)$cond); + + // No max. + unset($structure->max); + $cond = new condition($structure); + $this->assertEquals('{grade:#42 >= 3.89000}', (string)$cond); + + // No min. + unset($structure->min); + $structure->max = 32.768; + $cond = new condition($structure); + $this->assertEquals('{grade:#42 < 32.76800}', (string)$cond); + + // No nothing (only requires that grade exists). + unset($structure->max); + $cond = new condition($structure); + $this->assertEquals('{grade:#42}', (string)$cond); + } + + /** + * Tests the save() function. + */ + public function test_save() { + $structure = (object)array('id' => 19); + $cond = new condition($structure); + $structure->type = 'grade'; + $this->assertEquals($structure, $cond->save()); + + $structure = (object)array('id' => 19, 'min' => 4.12345, 'max' => 90); + $cond = new condition($structure); + $structure->type = 'grade'; + $this->assertEquals($structure, $cond->save()); + } + + /** + * Updates the grade of a user in the given assign module instance. + * + * @param stdClass $assignrow Assignment row from database + * @param int $userid User id + * @param float $grade Grade + */ + protected static function set_grade($assignrow, $userid, $grade) { + $grades = array(); + $grades[$userid] = (object)array( + 'rawgrade' => $grade, 'userid' => $userid); + $assignrow->cmidnumber = null; + assign_grade_item_update($assignrow, $grades); + } + + /** + * Tests the update_dependency_id() function. + */ + public function test_update_dependency_id() { + $cond = new condition((object)array('id' => 123)); + $this->assertFalse($cond->update_dependency_id('frogs', 123, 456)); + $this->assertFalse($cond->update_dependency_id('grade_items', 12, 34)); + $this->assertTrue($cond->update_dependency_id('grade_items', 123, 456)); + $after = $cond->save(); + $this->assertEquals(456, $after->id); + } +} diff --git a/availability/condition/grade/version.php b/availability/condition/grade/version.php new file mode 100644 index 00000000000..952956a2122 --- /dev/null +++ b/availability/condition/grade/version.php @@ -0,0 +1,29 @@ +. + +/** + * Version info. + * + * @package availability_grade + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->version = 2014022001; +$plugin->requires = 2014040401; +$plugin->component = 'availability_grade'; diff --git a/availability/condition/grade/yui/src/form/build.json b/availability/condition/grade/yui/src/form/build.json new file mode 100644 index 00000000000..e578ce250b0 --- /dev/null +++ b/availability/condition/grade/yui/src/form/build.json @@ -0,0 +1,10 @@ +{ + "name": "moodle-availability_grade-form", + "builds": { + "moodle-availability_grade-form": { + "jsfiles": [ + "form.js" + ] + } + } +} diff --git a/availability/condition/grade/yui/src/form/js/form.js b/availability/condition/grade/yui/src/form/js/form.js new file mode 100644 index 00000000000..669e2966e99 --- /dev/null +++ b/availability/condition/grade/yui/src/form/js/form.js @@ -0,0 +1,159 @@ +/** + * JavaScript for form editing grade conditions. + * + * @module moodle-availability_grade-form + */ +M.availability_grade = M.availability_grade || {}; + +/** + * @class M.availability_grade.form + * @extends M.core_availability.plugin + */ +M.availability_grade.form = Y.Object(M.core_availability.plugin); + +/** + * Grade items available for selection. + * + * @property grades + * @type Array + */ +M.availability_grade.form.grades = null; + +/** + * Initialises this plugin. + * + * @method initInner + * @param {Array} grades Array of objects containing gradeid => name + */ +M.availability_grade.form.initInner = function(grades) { + this.grades = grades; + this.nodesSoFar = 0; +}; + +M.availability_grade.form.getNode = function(json) { + // Increment number used for unique ids. + this.nodesSoFar++; + + // Create HTML structure. + var strings = M.str.availability_grade; + var html = ' ' + + ' %' + + '' + + ' %'; + var node = Y.Node.create('' + html + ''); + + // Set initial values. + if (json.id !== undefined && + node.one('select[name=id] > option[value=' + json.id + ']')) { + node.one('select[name=id]').set('value', json.id); + } + if (json.min !== undefined) { + node.one('input[name=min]').set('checked', true); + node.one('input[name=minval]').set('value', json.min); + } + if (json.max !== undefined) { + node.one('input[name=max]').set('checked', true); + node.one('input[name=maxval]').set('value', json.max); + } + + // Disables/enables text input fields depending on checkbox. + var updateCheckbox = function(check, focus) { + var input = check.ancestor('label').next('label').one('input'); + var checked = check.get('checked'); + input.set('disabled', !checked); + if (focus && checked) { + input.focus(); + } + return checked; + }; + node.all('input[type=checkbox]').each(updateCheckbox); + + // Add event handlers (first time only). + if (!M.availability_grade.form.addedEvents) { + M.availability_grade.form.addedEvents = true; + + var root = Y.one('#fitem_id_availabilityconditionsjson'); + root.delegate('change', function() { + // For the grade item, just update the form fields. + M.core_availability.form.update(); + }, '.availability_grade select[name=id]'); + + root.delegate('click', function() { + updateCheckbox(this, true); + }, '.availability_grade input[type=checkbox]'); + + root.delegate('valuechange', function() { + // For grade values, just update the form fields. + M.core_availability.form.update(); + }, '.availability_grade input[type=text]'); + } + + return node; +}; + +M.availability_grade.form.fillValue = function(value, node) { + value.id = parseInt(node.one('select[name=id]').get('value'), 10); + if (node.one('input[name=min]').get('checked')) { + value.min = this.getValue('minval', node); + } + if (node.one('input[name=max]').get('checked')) { + value.max = this.getValue('maxval', node); + } +}; + +/** + * Gets the numeric value of an input field. Supports decimal points (using + * dot or comma). + * + * @method getValue + * @return {Number|String} Value of field as number or string if not valid + */ +M.availability_grade.form.getValue = function(field, node) { + // Get field value. + var value = node.one('input[name=' + field + ']').get('value'); + + // If it is not a valid positive number, return false. + if (!(/^[0-9]+([.,][0-9]+)?$/.test(value))) { + return value; + } + + // Replace comma with dot and parse as floating-point. + var result = parseFloat(value.replace(',', '.')); + if (result < 0 || result > 100) { + return value; + } else { + return result; + } +}; + +M.availability_grade.form.fillErrors = function(errors, node) { + var value = {}; + this.fillValue(value, node); + + // Check grade item id. + if (value.id === 0) { + errors.push('availability_grade:error_selectgradeid'); + } + + // Check numeric values. + if ((value.min !== undefined && typeof(value.min) === 'string') || + (value.max !== undefined && typeof(value.max) === 'string')) { + errors.push('availability_grade:error_invalidnumber'); + } else if (value.min !== undefined && value.max !== undefined && + value.min >= value.max) { + errors.push('availability_grade:error_backwardrange'); + } +}; diff --git a/availability/condition/grade/yui/src/form/meta/form.json b/availability/condition/grade/yui/src/form/meta/form.json new file mode 100644 index 00000000000..af0851e2bcd --- /dev/null +++ b/availability/condition/grade/yui/src/form/meta/form.json @@ -0,0 +1,10 @@ +{ + "moodle-availability_grade-form": { + "requires": [ + "base", + "node", + "event", + "moodle-core_availability-form" + ] + } +} diff --git a/availability/condition/group/classes/condition.php b/availability/condition/group/classes/condition.php new file mode 100644 index 00000000000..f228b4d3a26 --- /dev/null +++ b/availability/condition/group/classes/condition.php @@ -0,0 +1,215 @@ +. + +/** + * Condition main class. + * + * @package availability_group + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace availability_group; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Condition main class. + * + * @package availability_group + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class condition extends \core_availability\condition { + /** @var array Array from group id => name */ + protected static $groupnames = array(); + + /** @var int ID of group that this condition requires, or 0 = any group */ + protected $groupid; + + /** + * Constructor. + * + * @param stdClass $structure Data structure from JSON decode + * @throws coding_exception If invalid data structure. + */ + public function __construct($structure) { + // Get group id. + if (!property_exists($structure, 'id')) { + $this->groupid = 0; + } else if (is_int($structure->id)) { + $this->groupid = $structure->id; + } else { + throw new \coding_exception('Invalid ->id for group condition'); + } + } + + public function save() { + $result = (object)array('type' => 'group'); + if ($this->groupid) { + $result->id = $this->groupid; + } + return $result; + } + + public function is_available($not, \core_availability\info $info, $grabthelot, $userid) { + $course = $info->get_course(); + $context = \context_course::instance($course->id); + $allow = true; + if (!has_capability('moodle/site:accessallgroups', $context, $userid)) { + // Get all groups the user belongs to. + $groups = $info->get_modinfo()->get_groups(); + if ($this->groupid) { + $allow = in_array($this->groupid, $groups); + } else { + // No specific group. Allow if they belong to any group at all. + $allow = $groups ? true : false; + } + + // The NOT condition applies before accessallgroups (i.e. if you + // set something to be available to those NOT in group X, + // people with accessallgroups can still access it even if + // they are in group X). + if ($not) { + $allow = !$allow; + } + } + return $allow; + } + + public function get_description($full, $not, \core_availability\info $info) { + global $DB; + + if ($this->groupid) { + // Need to get the name for the group. Unfortunately this requires + // a database query. To save queries, get all groups for course at + // once in a static cache. + $course = $info->get_course(); + if (!array_key_exists($this->groupid, self::$groupnames)) { + $coursegroups = $DB->get_records( + 'groups', array('courseid' => $course->id), '', 'id, name'); + foreach ($coursegroups as $rec) { + self::$groupnames[$rec->id] = $rec->name; + } + } + + // If it still doesn't exist, it must have been misplaced. + if (!array_key_exists($this->groupid, self::$groupnames)) { + $name = get_string('missing', 'availability_group'); + } else { + $context = \context_course::instance($course->id); + $name = format_string(self::$groupnames[$this->groupid], true, + array('context' => $context)); + } + } else { + return get_string($not ? 'requires_notanygroup' : 'requires_anygroup', + 'availability_group'); + } + + return get_string($not ? 'requires_notgroup' : 'requires_group', + 'availability_group', $name); + } + + protected function get_debug_string() { + return $this->groupid ? '#' . $this->groupid : 'any'; + } + + public function update_after_restore($restoreid, $courseid, \base_logger $logger, $name) { + global $DB; + if (!$this->groupid) { + return false; + } + $rec = \restore_dbops::get_backup_ids_record($restoreid, 'group', $this->groupid); + 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('groups', + array('id' => $this->groupid, 'courseid' => $courseid))) { + return false; + } + // Otherwise it's a warning. + $this->groupid = -1; + $logger->process('Restored item (' . $name . + ') has availability condition on group that was not restored', + \backup::LOG_WARNING); + } else { + $this->groupid = (int)$rec->newitemid; + } + return true; + } + + public function update_dependency_id($table, $oldid, $newid) { + if ($table === 'groups' && (int)$this->groupid === (int)$oldid) { + $this->groupid = $newid; + return true; + } else { + return false; + } + } + + /** + * Wipes the static cache used to store grouping names. + */ + public static function wipe_static_cache() { + self::$groupnames = array(); + } + + public function is_applied_to_user_lists() { + // Group conditions are assumed to be 'permanent', so they affect the + // display of user lists for activities. + return true; + } + + public function filter_user_list(array $users, $not, \core_availability\info $info, + \core_availability\capability_checker $checker) { + global $CFG, $DB; + require_once($CFG->libdir . '/grouplib.php'); + $course = $info->get_course(); + + // List users for this course who match the condition. + if ($this->groupid) { + $groupusers = groups_get_members($this->groupid, 'u.id', 'u.id ASC'); + } else { + $groupusers = $DB->get_records_sql(" + SELECT DISTINCT gm.userid + FROM {groups} g + JOIN {groups_members} gm ON gm.groupid = g.id + WHERE g.courseid = ?", array($course->id)); + } + + // List users who have access all groups. + $aagusers = $checker->get_users_by_capability('moodle/site:accessallgroups'); + + // Filter the user list. + $result = array(); + foreach ($users as $id => $user) { + // Always include users with access all groups. + if (array_key_exists($id, $aagusers)) { + $result[$id] = $user; + continue; + } + // Other users are included or not based on group membership. + $allow = array_key_exists($id, $groupusers); + if ($not) { + $allow = !$allow; + } + if ($allow) { + $result[$id] = $user; + } + } + return $result; + } +} diff --git a/availability/condition/group/classes/frontend.php b/availability/condition/group/classes/frontend.php new file mode 100644 index 00000000000..31f1167c2ec --- /dev/null +++ b/availability/condition/group/classes/frontend.php @@ -0,0 +1,91 @@ +. + +/** + * Front-end class. + * + * @package availability_group + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace availability_group; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Front-end class. + * + * @package availability_group + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class frontend extends \core_availability\frontend { + /** @var array Array of group info for course */ + protected $allgroups; + /** @var int Course id that $allgroups is for */ + protected $allgroupscourseid; + + protected function get_javascript_strings() { + return array('anygroup'); + } + + protected function get_javascript_init_params($course, \cm_info $cm = null, + \section_info $section = null) { + // Get all groups for course. + $groups = $this->get_all_groups($course->id); + + // Change to JS array format and return. + $jsarray = array(); + $context = \context_course::instance($course->id); + foreach ($groups as $rec) { + $jsarray[] = (object)array('id' => $rec->id, 'name' => + format_string($rec->name, true, array('context' => $context))); + } + return array($jsarray); + } + + /** + * Gets all groups for the given course. + * + * @param int $courseid Course id + * @return array Array of all the group objects + */ + protected function get_all_groups($courseid) { + global $CFG; + require_once($CFG->libdir . '/grouplib.php'); + + if ($courseid != $this->allgroupscourseid) { + $this->allgroups = groups_get_all_groups($courseid, 0, 0, 'g.id, g.name'); + $this->allgroupscourseid = $courseid; + } + return $this->allgroups; + } + + protected function allow_add($course, \cm_info $cm = null, + \section_info $section = null) { + global $CFG; + + // Group option can be used on sections, and on modules but only + // if groupmembersonly is turned off. (To avoid confusion.) + if (!is_null($cm) && $CFG->enablegroupmembersonly) { + return false; + } + + // Only show this option if there are some groups. + return count($this->get_all_groups($course->id)) > 0; + } +} diff --git a/availability/condition/group/lang/en/availability_group.php b/availability/condition/group/lang/en/availability_group.php new file mode 100644 index 00000000000..621df0dcc83 --- /dev/null +++ b/availability/condition/group/lang/en/availability_group.php @@ -0,0 +1,34 @@ +. + +/** + * Language strings. + * + * @package availability_group + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['anygroup'] = '(Any group)'; +$string['description'] = 'Allow only students who belong to a specified group, or all groups.'; +$string['missing'] = '(Missing group)'; +$string['pluginname'] = 'Restriction by group'; +$string['error_selectgroup'] = 'You must select a group.'; +$string['requires_anygroup'] = 'You belong to any group'; +$string['requires_group'] = 'You belong to {$a}'; +$string['requires_notanygroup'] = 'You do not belong to any group'; +$string['requires_notgroup'] = 'You do not belong to {$a}'; +$string['title'] = 'Group'; diff --git a/availability/condition/group/tests/behat/availability_group.feature b/availability/condition/group/tests/behat/availability_group.feature new file mode 100644 index 00000000000..91bcfaf2fee --- /dev/null +++ b/availability/condition/group/tests/behat/availability_group.feature @@ -0,0 +1,109 @@ +@availability @availability_group +Feature: availability_group + In order to control student access to activities + As a teacher + I need to set group conditions which prevent student access + + Background: + Given the following "courses" exist: + | fullname | shortname | format | enablecompletion | + | Course 1 | C1 | topics | 1 | + And the following "users" exist: + | username | + | teacher1 | + | student1 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And I log in as "admin" + And I set the following administration settings values: + | Enable conditional access | 1 | + And I log out + + @javascript + Scenario: Test condition + # Basic setup. + Given I log in as "teacher1" + And I follow "Course 1" + And I turn editing mode on + + # Start to add a Page. If there aren't any groups, there's no Group option. + And I add a "Page" to section "1" + And I expand all fieldsets + And I click on "Add restriction..." "button" + Then "Group" "button" should not exist in the "Add restriction..." "dialogue" + And I click on "Cancel" "button" in the "Add restriction..." "dialogue" + + # Back to course page but add groups. + Given the following "groups" exist: + | name | course | idnumber | + | G1 | C1 | GI1 | + | G2 | C1 | GI2 | + # This step used to be 'And I follow "C1"', but Chrome thinks the breadcrumb + # is not clickable, so we'll go via the home page instead. + And I am on homepage + And I follow "Course 1" + And I add a "Page" to section "1" + And I expand all fieldsets + And I click on "Add restriction..." "button" + Then "Group" "button" should exist in the "Add restriction..." "dialogue" + + # Page P1 any group. + Given I click on "Group" "button" + And I set the field "Group" to "(Any group)" + And I click on ".availability-item .availability-eye img" "css_element" + And I set the following fields to these values: + | Name | P1 | + | Description | x | + | Page content | x | + And I click on "Save and return to course" "button" + + # Page P2 with group G1. + And I add a "Page" to section "2" + And I set the following fields to these values: + | Name | P2 | + | Description | x | + | Page content | x | + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Group" "button" + And I set the field "Group" to "G1" + And I click on ".availability-item .availability-eye img" "css_element" + And I click on "Save and return to course" "button" + + # Page P3 with group G2 + And I add a "Page" to section "3" + And I set the following fields to these values: + | Name | P3 | + | Description | x | + | Page content | x | + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Group" "button" + And I set the field "Group" to "G2" + And I click on ".availability-item .availability-eye img" "css_element" + And I click on "Save and return to course" "button" + + # Log back in as student. + When I log out + And I log in as "student1" + And I follow "Course 1" + + # No pages should appear yet. + Then I should not see "P1" in the "region-main" "region" + And I should not see "P2" in the "region-main" "region" + And I should not see "P3" in the "region-main" "region" + + # Add to groups and log out/in again. + Given the following "group members" exist: + | user | group | + | student1 | GI1 | + And I log out + And I log in as "student1" + And I follow "Course 1" + + # P1 (any groups) and P2 should show but not P3. + Then I should see "P1" in the "region-main" "region" + And I should see "P2" in the "region-main" "region" + And I should not see "P3" in the "region-main" "region" diff --git a/availability/condition/group/tests/condition_test.php b/availability/condition/group/tests/condition_test.php new file mode 100644 index 00000000000..061840ef36b --- /dev/null +++ b/availability/condition/group/tests/condition_test.php @@ -0,0 +1,225 @@ +. + +/** + * Unit tests for the condition. + * + * @package availability_group + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +use availability_group\condition; + +/** + * Unit tests for the condition. + * + * @package availability_group + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class availability_group_condition_testcase extends advanced_testcase { + /** + * 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'); + } + + /** + * Tests constructing and using condition. + */ + public function test_usage() { + global $CFG, $USER; + $this->resetAfterTest(); + $CFG->enableavailability = true; + + // Erase static cache before test. + condition::wipe_static_cache(); + + // Make a test course and user. + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + $user = $generator->create_user(); + $generator->enrol_user($user->id, $course->id); + $info = new \core_availability\mock_info($course, $user->id); + + // Make 2 test groups, one in a grouping and one not. + $grouping = $generator->create_grouping(array('courseid' => $course->id)); + $group1 = $generator->create_group(array('courseid' => $course->id, 'name' => 'G1!')); + groups_assign_grouping($grouping->id, $group1->id); + $group2 = $generator->create_group(array('courseid' => $course->id, 'name' => 'G2!')); + + // Do test (not in group). + $cond = new condition((object)array('id' => (int)$group1->id)); + + // Check if available (when not available). + $this->assertFalse($cond->is_available(false, $info, true, $user->id)); + $information = $cond->get_description(false, false, $info); + $this->assertRegExp('~You belong to.*G1!~', $information); + $this->assertTrue($cond->is_available(true, $info, true, $user->id)); + + // Add user to groups and refresh cache. + groups_add_member($group1, $user); + groups_add_member($group2, $user); + get_fast_modinfo($course->id, 0, true); + + // Recheck. + $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); + $this->assertRegExp('~do not belong to.*G1!~', $information); + + // Check group 2 works also. + $cond = new condition((object)array('id' => (int)$group2->id)); + $this->assertTrue($cond->is_available(false, $info, true, $user->id)); + + // What about an 'any group' condition? + $cond = new condition((object)array()); + $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); + $this->assertRegExp('~do not belong to any~', $information); + + // Admin user doesn't belong to a group, but they can access it + // either way (positive or NOT). + $this->setAdminUser(); + $this->assertTrue($cond->is_available(false, $info, true, $USER->id)); + $this->assertTrue($cond->is_available(true, $info, true, $USER->id)); + + // Group that doesn't exist uses 'missing' text. + $cond = new condition((object)array('id' => $group2->id + 1000)); + $this->assertFalse($cond->is_available(false, $info, true, $user->id)); + $information = $cond->get_description(false, false, $info); + $this->assertRegExp('~You belong to.*\(Missing group\)~', $information); + } + + /** + * Tests the constructor including error conditions. Also tests the + * string conversion feature (intended for debugging only). + */ + public function test_constructor() { + // Invalid id (not int). + $structure = (object)array('id' => 'bourne'); + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Invalid ->id', $e->getMessage()); + } + + // Valid (with id). + $structure->id = 123; + $cond = new condition($structure); + $this->assertEquals('{group:#123}', (string)$cond); + + // Valid (no id). + unset($structure->id); + $cond = new condition($structure); + $this->assertEquals('{group:any}', (string)$cond); + } + + /** + * Tests the save() function. + */ + public function test_save() { + $structure = (object)array('id' => 123); + $cond = new condition($structure); + $structure->type = 'group'; + $this->assertEquals($structure, $cond->save()); + + $structure = (object)array(); + $cond = new condition($structure); + $structure->type = 'group'; + $this->assertEquals($structure, $cond->save()); + } + + /** + * Tests the update_dependency_id() function. + */ + public function test_update_dependency_id() { + $cond = new condition((object)array('id' => 123)); + $this->assertFalse($cond->update_dependency_id('frogs', 123, 456)); + $this->assertFalse($cond->update_dependency_id('groups', 12, 34)); + $this->assertTrue($cond->update_dependency_id('groups', 123, 456)); + $after = $cond->save(); + $this->assertEquals(456, $after->id); + } + + /** + * Tests the filter_users (bulk checking) function. + */ + public function test_filter_users() { + global $DB; + $this->resetAfterTest(); + + // Erase static cache before test. + condition::wipe_static_cache(); + + // Make a test course and some users. + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + $roleids = $DB->get_records_menu('role', null, '', 'shortname, id'); + $teacher = $generator->create_user(); + $generator->enrol_user($teacher->id, $course->id, $roleids['teacher']); + $allusers = array($teacher->id => $teacher); + $students = array(); + for ($i = 0; $i < 3; $i++) { + $student = $generator->create_user(); + $students[$i] = $student; + $generator->enrol_user($student->id, $course->id, $roleids['student']); + $allusers[$student->id] = $student; + } + $info = new \core_availability\mock_info($course); + + // Make test groups. + $group1 = $generator->create_group(array('courseid' => $course->id)); + $group2 = $generator->create_group(array('courseid' => $course->id)); + + // Assign students to groups as follows (teacher is not in a group): + // 0: no groups. + // 1: in group 1. + // 2: in group 2. + groups_add_member($group1, $students[1]); + groups_add_member($group2, $students[2]); + + // Test 'any group' condition. + $checker = new \core_availability\capability_checker($info->get_context()); + $cond = new condition((object)array()); + $result = array_keys($cond->filter_user_list($allusers, false, $info, $checker)); + ksort($result); + $this->assertEquals(array($teacher->id, $students[1]->id, $students[2]->id), $result); + + // Test NOT version (note that teacher can still access because AAG works + // both ways). + $result = array_keys($cond->filter_user_list($allusers, true, $info, $checker)); + ksort($result); + $this->assertEquals(array($teacher->id, $students[0]->id), $result); + + // Test specific group. + $cond = new condition((object)array('id' => (int)$group1->id)); + $result = array_keys($cond->filter_user_list($allusers, false, $info, $checker)); + ksort($result); + $this->assertEquals(array($teacher->id, $students[1]->id), $result); + $result = array_keys($cond->filter_user_list($allusers, true, $info, $checker)); + ksort($result); + $this->assertEquals(array($teacher->id, $students[0]->id, $students[2]->id), $result); + } +} diff --git a/availability/condition/group/version.php b/availability/condition/group/version.php new file mode 100644 index 00000000000..c658c6bd562 --- /dev/null +++ b/availability/condition/group/version.php @@ -0,0 +1,29 @@ +. + +/** + * Version info. + * + * @package availability_group + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->version = 2014030400; +$plugin->requires = 2014040401; +$plugin->component = 'availability_group'; diff --git a/availability/condition/group/yui/src/form/build.json b/availability/condition/group/yui/src/form/build.json new file mode 100644 index 00000000000..68e0a5402a2 --- /dev/null +++ b/availability/condition/group/yui/src/form/build.json @@ -0,0 +1,10 @@ +{ + "name": "moodle-availability_group-form", + "builds": { + "moodle-availability_group-form": { + "jsfiles": [ + "form.js" + ] + } + } +} diff --git a/availability/condition/group/yui/src/form/js/form.js b/availability/condition/group/yui/src/form/js/form.js new file mode 100644 index 00000000000..9d06aed4bff --- /dev/null +++ b/availability/condition/group/yui/src/form/js/form.js @@ -0,0 +1,87 @@ +/** + * JavaScript for form editing group conditions. + * + * @module moodle-availability_group-form + */ +M.availability_group = M.availability_group || {}; + +/** + * @class M.availability_group.form + * @extends M.core_availability.plugin + */ +M.availability_group.form = Y.Object(M.core_availability.plugin); + +/** + * Groups available for selection (alphabetical order). + * + * @property groups + * @type Array + */ +M.availability_group.form.groups = null; + +/** + * Initialises this plugin. + * + * @method initInner + * @param {Array} groups Array of objects containing groupid => name + */ +M.availability_group.form.initInner = function(groups) { + this.groups = groups; +}; + +M.availability_group.form.getNode = function(json) { + // Create HTML structure. + var strings = M.str.availability_group; + var html = ''; + var node = Y.Node.create('' + html + ''); + + // Set initial values (leave default 'choose' if creating afresh). + if (json.creating === undefined) { + if (json.id !== undefined && + node.one('select[name=id] > option[value=' + json.id + ']')) { + node.one('select[name=id]').set('value', json.id); + } else if (json.id === undefined) { + node.one('select[name=id]').set('value', 'any'); + } + } + + // Add event handlers (first time only). + if (!M.availability_group.form.addedEvents) { + M.availability_group.form.addedEvents = true; + var root = Y.one('#fitem_id_availabilityconditionsjson'); + root.delegate('change', function() { + // Just update the form fields. + M.core_availability.form.update(); + }, '.availability_group select'); + } + + return node; +}; + +M.availability_group.form.fillValue = function(value, node) { + var selected = node.one('select[name=id]').get('value'); + if (selected === 'choose') { + value.id = 'choose'; + } else if (selected !== 'any') { + value.id = parseInt(selected, 10); + } +}; + +M.availability_group.form.fillErrors = function(errors, node) { + var value = {}; + this.fillValue(value, node); + + // Check group item id. + if (value.id && value.id === 'choose') { + errors.push('availability_group:error_selectgroup'); + } +}; diff --git a/availability/condition/group/yui/src/form/meta/form.json b/availability/condition/group/yui/src/form/meta/form.json new file mode 100644 index 00000000000..145fa6cb049 --- /dev/null +++ b/availability/condition/group/yui/src/form/meta/form.json @@ -0,0 +1,10 @@ +{ + "moodle-availability_group-form": { + "requires": [ + "base", + "node", + "event", + "moodle-core_availability-form" + ] + } +} diff --git a/availability/condition/grouping/classes/condition.php b/availability/condition/grouping/classes/condition.php new file mode 100644 index 00000000000..60206702e68 --- /dev/null +++ b/availability/condition/grouping/classes/condition.php @@ -0,0 +1,242 @@ +. + +/** + * Condition main class. + * + * @package availability_grouping + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace availability_grouping; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Condition main class. + * + * @package availability_grouping + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class condition extends \core_availability\condition { + /** @var array Array from grouping id => name */ + protected static $groupingnames = array(); + + /** @var int ID of grouping that this condition requires */ + protected $groupingid = 0; + + /** @var bool If true, indicates that activity $cm->grouping is used */ + protected $activitygrouping = false; + + /** + * Constructor. + * + * @param stdClass $structure Data structure from JSON decode + * @throws coding_exception If invalid data structure. + */ + public function __construct($structure) { + // Get grouping id. + if (isset($structure->id)) { + if (is_int($structure->id)) { + $this->groupingid = $structure->id; + } else { + throw new \coding_exception('Invalid ->id for grouping condition'); + } + } else if (isset($structure->activity)) { + if (is_bool($structure->activity) && $structure->activity) { + $this->activitygrouping = true; + } else { + throw new \coding_exception('Invalid ->activity for grouping condition'); + } + } else { + throw new \coding_exception('Missing ->id / ->activity for grouping condition'); + } + } + + public function save() { + $result = (object)array('type' => 'grouping'); + if ($this->groupingid) { + $result->id = $this->groupingid; + } else { + $result->activity = true; + } + return $result; + } + + public function is_available($not, \core_availability\info $info, $grabthelot, $userid) { + $context = \context_course::instance($info->get_course()->id); + $allow = true; + if (!has_capability('moodle/site:accessallgroups', $context, $userid)) { + // If the activity has 'group members only' and you don't have accessallgroups... + $groups = $info->get_modinfo()->get_groups($this->get_grouping_id($info)); + if (!$groups) { + // ...and you don't belong to a group, then set it so you can't see/access it. + $allow = false; + } + + // The NOT condition applies before accessallgroups (i.e. if you + // set something to be available to those NOT in grouping X, + // people with accessallgroups can still access it even if + // they are in grouping X). + if ($not) { + $allow = !$allow; + } + } + return $allow; + } + + /** + * Gets the actual grouping id for the condition. This is either a specified + * id, or a special flag indicating that we use the one for the current cm. + * + * @param \core_availability\info $info Info about context cm + * @return int Grouping id + * @throws \coding_exception If it's set to use a cm but there isn't grouping + */ + protected function get_grouping_id(\core_availability\info $info) { + if ($this->activitygrouping) { + $groupingid = $info->get_course_module()->groupingid; + if (!$groupingid) { + throw new \coding_exception( + 'Not supposed to be able to turn on activitygrouping when no grouping'); + } + return $groupingid; + } else { + return $this->groupingid; + } + } + + public function get_description($full, $not, \core_availability\info $info) { + global $DB; + $course = $info->get_course(); + + // Need to get the name for the grouping. Unfortunately this requires + // a database query. To save queries, get all groupings for course at + // once in a static cache. + $groupingid = $this->get_grouping_id($info); + if (!array_key_exists($groupingid, self::$groupingnames)) { + $coursegroupings = $DB->get_records( + 'groupings', array('courseid' => $course->id), '', 'id, name'); + foreach ($coursegroupings as $rec) { + self::$groupingnames[$rec->id] = $rec->name; + } + } + + // If it still doesn't exist, it must have been misplaced. + if (!array_key_exists($groupingid, self::$groupingnames)) { + $name = get_string('missing', 'availability_grouping'); + } else { + $context = \context_course::instance($course->id); + $name = format_string(self::$groupingnames[$groupingid], true, + array('context' => $context)); + } + + return get_string($not ? 'requires_notgrouping' : 'requires_grouping', + 'availability_grouping', $name); + } + + protected function get_debug_string() { + if ($this->activitygrouping) { + return 'CM'; + } else { + return '#' . $this->groupingid; + } + } + + public function update_after_restore($restoreid, $courseid, \base_logger $logger, $name) { + global $DB; + if (!$this->groupingid) { + // If using 'same as activity' option, no need to change it. + return false; + } + $rec = \restore_dbops::get_backup_ids_record($restoreid, 'grouping', $this->groupingid); + 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('groupings', + array('id' => $this->groupingid, 'courseid' => $courseid))) { + return false; + } + // Otherwise it's a warning. + $this->groupingid = -1; + $logger->process('Restored item (' . $name . + ') has availability condition on grouping that was not restored', + \backup::LOG_WARNING); + } else { + $this->groupingid = (int)$rec->newitemid; + } + return true; + } + + public function update_dependency_id($table, $oldid, $newid) { + if ($table === 'groupings' && (int)$this->groupingid === (int)$oldid) { + $this->groupingid = $newid; + return true; + } else { + return false; + } + } + + /** + * Wipes the static cache used to store grouping names. + */ + public static function wipe_static_cache() { + self::$groupingnames = array(); + } + + public function is_applied_to_user_lists() { + // Grouping conditions are assumed to be 'permanent', so they affect the + // display of user lists for activities. + return true; + } + + public function filter_user_list(array $users, $not, \core_availability\info $info, + \core_availability\capability_checker $checker) { + global $CFG, $DB; + + // List users for this course who match the condition. + $groupingusers = $DB->get_records_sql(" + SELECT DISTINCT gm.userid + FROM {groupings_groups} gg + JOIN {groups_members} gm ON gm.groupid = gg.groupid + WHERE gg.groupingid = ?", + array($this->get_grouping_id($info))); + + // List users who have access all groups. + $aagusers = $checker->get_users_by_capability('moodle/site:accessallgroups'); + + // Filter the user list. + $result = array(); + foreach ($users as $id => $user) { + // Always include users with access all groups. + if (array_key_exists($id, $aagusers)) { + $result[$id] = $user; + continue; + } + // Other users are included or not based on grouping membership. + $allow = array_key_exists($id, $groupingusers); + if ($not) { + $allow = !$allow; + } + if ($allow) { + $result[$id] = $user; + } + } + return $result; + } +} diff --git a/availability/condition/grouping/classes/frontend.php b/availability/condition/grouping/classes/frontend.php new file mode 100644 index 00000000000..9dfb5e5a145 --- /dev/null +++ b/availability/condition/grouping/classes/frontend.php @@ -0,0 +1,89 @@ +. + +/** + * Front-end class. + * + * @package availability_grouping + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace availability_grouping; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Front-end class. + * + * @package availability_grouping + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class frontend extends \core_availability\frontend { + /** @var array Array of grouping info for course */ + protected $allgroupings; + /** @var int Course id that $allgroupings is for */ + protected $allgroupingscourseid; + + protected function get_javascript_init_params($course, \cm_info $cm = null, + \section_info $section = null) { + // Get all groups for course. + $groupings = $this->get_all_groupings($course->id); + + // Change to JS array format and return. + $jsarray = array(); + $context = \context_course::instance($course->id); + foreach ($groupings as $rec) { + $jsarray[] = (object)array('id' => $rec->id, 'name' => + format_string($rec->name, true, array('context' => $context))); + } + return array($jsarray); + } + + /** + * Gets all the groupings on the course. + * + * @param int $courseid Course id + * @return array Array of grouping objects + */ + protected function get_all_groupings($courseid) { + global $DB; + if ($courseid != $this->allgroupingscourseid) { + $this->allgroupings = $DB->get_records('groupings', + array('courseid' => $courseid), 'id, name'); + $this->allgroupingscourseid = $courseid; + } + return $this->allgroupings; + } + + protected function allow_add($course, \cm_info $cm = null, + \section_info $section = null) { + global $CFG, $DB; + + // Grouping option can be used on sections, and on modules but only + // if groupmembersonly is turned off. (To avoid confusion.) + if (!is_null($cm) && $CFG->enablegroupmembersonly) { + return false; + } + + // Check if groupings are in use for the course. (Unlike the 'group' + // condition there is no case where you might want to set up the + // condition before you set a grouping - there is no 'any grouping' + // option.) + return count($this->get_all_groupings($course->id)) > 0; + } +} diff --git a/availability/condition/grouping/lang/en/availability_grouping.php b/availability/condition/grouping/lang/en/availability_grouping.php new file mode 100644 index 00000000000..eeaa46259ee --- /dev/null +++ b/availability/condition/grouping/lang/en/availability_grouping.php @@ -0,0 +1,31 @@ +. + +/** + * Language strings. + * + * @package availability_grouping + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['description'] = 'Allow only students who belong to a group within a specified grouping.'; +$string['error_selectgrouping'] = 'You must select a grouping.'; +$string['missing'] = '(Missing grouping)'; +$string['pluginname'] = 'Restriction by grouping'; +$string['requires_grouping'] = 'You belong to a group in {$a}'; +$string['requires_notgrouping'] = 'You do not belong to a group in {$a}'; +$string['title'] = 'Grouping'; diff --git a/availability/condition/grouping/tests/behat/availability_grouping.feature b/availability/condition/grouping/tests/behat/availability_grouping.feature new file mode 100644 index 00000000000..9865ce9ab17 --- /dev/null +++ b/availability/condition/grouping/tests/behat/availability_grouping.feature @@ -0,0 +1,100 @@ +@availability @availability_grouping +Feature: availability_grouping + In order to control student access to activities + As a teacher + I need to set grouping conditions which prevent student access + + Background: + Given the following "courses" exist: + | fullname | shortname | format | enablecompletion | + | Course 1 | C1 | topics | 1 | + And the following "users" exist: + | username | + | teacher1 | + | student1 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "groups" exist: + | name | course | idnumber | + | G1 | C1 | GI1 | + And the following "group members" exist: + | user | group | + | student1 | GI1 | + And I log in as "admin" + And I set the following administration settings values: + | Enable conditional access | 1 | + And I log out + + @javascript + Scenario: Test condition + # Basic setup. + Given I log in as "teacher1" + And I follow "Course 1" + And I turn editing mode on + + # Start to add a Page. If there aren't any groupings, there's no Grouping option. + And I add a "Page" to section "1" + And I expand all fieldsets + And I click on "Add restriction..." "button" + Then "Grouping" "button" should not exist in the "Add restriction..." "dialogue" + And I click on "Cancel" "button" in the "Add restriction..." "dialogue" + + # Back to course page but add groups. + # This step used to be 'And I follow "C1"', but Chrome thinks the breadcrumb + # is not clickable, so we'll go via the home page instead. + And I am on homepage + And I follow "Course 1" + And the following "groupings" exist: + | name | course | idnumber | + | GX1 | C1 | GXI1 | + | GX2 | C1 | GXI2 | + And I add a "Page" to section "1" + And I expand all fieldsets + And I click on "Add restriction..." "button" + Then "Grouping" "button" should exist in the "Add restriction..." "dialogue" + + # Page P1 grouping GX1. + Given I click on "Grouping" "button" + And I set the field "Grouping" to "GX1" + And I click on ".availability-item .availability-eye img" "css_element" + And I set the following fields to these values: + | Name | P1 | + | Description | x | + | Page content | x | + And I click on "Save and return to course" "button" + + # Page P2 with grouping GX2. + And I add a "Page" to section "2" + And I set the following fields to these values: + | Name | P2 | + | Description | x | + | Page content | x | + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Grouping" "button" + And I set the field "Grouping" to "GX2" + And I click on ".availability-item .availability-eye img" "css_element" + And I click on "Save and return to course" "button" + + # Log back in as student. + When I log out + And I log in as "student1" + And I follow "Course 1" + + # No pages should appear yet. + Then I should not see "P1" in the "region-main" "region" + And I should not see "P2" in the "region-main" "region" + + # Add group to grouping and log out/in again. + And I log out + And the following "grouping groups" exist: + | grouping | group | + | GXI1 | GI1 | + And I log in as "student1" + And I follow "Course 1" + + # P1 should show but not B2. + Then I should see "P1" in the "region-main" "region" + And I should not see "P2" in the "region-main" "region" diff --git a/availability/condition/grouping/tests/condition_test.php b/availability/condition/grouping/tests/condition_test.php new file mode 100644 index 00000000000..d9d48b49c60 --- /dev/null +++ b/availability/condition/grouping/tests/condition_test.php @@ -0,0 +1,273 @@ +. + +/** + * Unit tests for the condition. + * + * @package availability_grouping + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +use availability_grouping\condition; + +/** + * Unit tests for the condition. + * + * @package availability_grouping + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class availability_grouping_condition_testcase extends advanced_testcase { + /** + * 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'); + } + + /** + * Tests constructing and using condition. + */ + public function test_usage() { + global $CFG, $USER; + $this->resetAfterTest(); + $CFG->enableavailability = true; + + // Erase static cache before test. + condition::wipe_static_cache(); + + // Make a test course and user. + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + $user = $generator->create_user(); + $generator->enrol_user($user->id, $course->id); + $info = new \core_availability\mock_info($course, $user->id); + + // Make a test grouping and group. + $grouping = $generator->create_grouping(array('courseid' => $course->id, + 'name' => 'Grouping!')); + $group = $generator->create_group(array('courseid' => $course->id)); + groups_assign_grouping($grouping->id, $group->id); + + // Do test (not in grouping). + $structure = (object)array('type' => 'grouping', 'id' => (int)$grouping->id); + $cond = new condition($structure); + + // Check if available (when not available). + $this->assertFalse($cond->is_available(false, $info, true, $user->id)); + $information = $cond->get_description(false, false, $info); + $this->assertRegExp('~belong to a group in.*Grouping!~', $information); + $this->assertTrue($cond->is_available(true, $info, true, $user->id)); + + // Add user to grouping and refresh cache. + groups_add_member($group, $user); + get_fast_modinfo($course->id, 0, true); + + // Recheck. + $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); + $this->assertRegExp('~do not belong to a group in.*Grouping!~', $information); + + // Admin user doesn't belong to the grouping, but they can access it + // either way (positive or NOT) because of accessallgroups. + $this->setAdminUser(); + $infoadmin = new \core_availability\mock_info($course, $USER->id); + $this->assertTrue($cond->is_available(false, $infoadmin, true, $USER->id)); + $this->assertTrue($cond->is_available(true, $infoadmin, true, $USER->id)); + + // Grouping that doesn't exist uses 'missing' text. + $cond = new condition((object)array('id' => $grouping->id + 1000)); + $this->assertFalse($cond->is_available(false, $info, true, $user->id)); + $information = $cond->get_description(false, false, $info); + $this->assertRegExp('~belong to a group in.*(Missing grouping)~', $information); + + // We need an actual cm object to test the 'grouping from cm' option. + $pagegen = $generator->get_plugin_generator('mod_page'); + $page = $pagegen->create_instance(array('course' => $course->id, + 'groupingid' => $grouping->id, 'availability' => + '{"op":"|","show":true,"c":[{"type":"grouping","activity":true}]}')); + rebuild_course_cache($course->id, true); + + // Check if available using the 'from course-module' grouping option. + $modinfo = get_fast_modinfo($course, $user->id); + $cm = $modinfo->get_cm($page->cmid); + $info = new \core_availability\info_module($cm); + $information = ''; + $this->assertTrue($info->is_available($information, false, $user->id)); + + // Remove user from grouping again and recheck. + groups_remove_member($group, $user); + get_fast_modinfo($course->id, 0, true); + $this->assertFalse($info->is_available($information, false, $user->id)); + $this->assertRegExp('~belong to a group in.*Grouping!~', $information); + } + + /** + * Tests the constructor including error conditions. Also tests the + * string conversion feature (intended for debugging only). + */ + public function test_constructor() { + // No parameters. + $structure = new stdClass(); + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing ->id / ->activity', $e->getMessage()); + } + + // Invalid id (not int). + $structure->id = 'bourne'; + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Invalid ->id', $e->getMessage()); + } + + // Invalid activity option (not bool). + unset($structure->id); + $structure->activity = 42; + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Invalid ->activity', $e->getMessage()); + } + + // Invalid activity option (false). + $structure->activity = false; + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Invalid ->activity', $e->getMessage()); + } + + // Valid with id. + $structure->id = 123; + $cond = new condition($structure); + $this->assertEquals('{grouping:#123}', (string)$cond); + + // Valid with activity. + unset($structure->id); + $structure->activity = true; + $cond = new condition($structure); + $this->assertEquals('{grouping:CM}', (string)$cond); + } + + /** + * Tests the save() function. + */ + public function test_save() { + $structure = (object)array('id' => 123); + $cond = new condition($structure); + $structure->type = 'grouping'; + $this->assertEquals($structure, $cond->save()); + + $structure = (object)array('activity' => true); + $cond = new condition($structure); + $structure->type = 'grouping'; + $this->assertEquals($structure, $cond->save()); + } + + /** + * Tests the update_dependency_id() function. + */ + public function test_update_dependency_id() { + $cond = new condition((object)array('id' => 123)); + $this->assertFalse($cond->update_dependency_id('frogs', 123, 456)); + $this->assertFalse($cond->update_dependency_id('groupings', 12, 34)); + $this->assertTrue($cond->update_dependency_id('groupings', 123, 456)); + $after = $cond->save(); + $this->assertEquals(456, $after->id); + + $cond = new condition((object)array('activity' => true)); + $this->assertFalse($cond->update_dependency_id('frogs', 123, 456)); + } + + /** + * Tests the filter_users (bulk checking) function. + */ + public function test_filter_users() { + global $DB, $CFG; + $this->resetAfterTest(); + $CFG->enableavailability = true; + + // Erase static cache before test. + condition::wipe_static_cache(); + + // Make a test course and some users. + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + $roleids = $DB->get_records_menu('role', null, '', 'shortname, id'); + $teacher = $generator->create_user(); + $generator->enrol_user($teacher->id, $course->id, $roleids['teacher']); + $allusers = array($teacher->id => $teacher); + $students = array(); + for ($i = 0; $i < 3; $i++) { + $student = $generator->create_user(); + $students[$i] = $student; + $generator->enrol_user($student->id, $course->id, $roleids['student']); + $allusers[$student->id] = $student; + } + $info = new \core_availability\mock_info($course); + $checker = new \core_availability\capability_checker($info->get_context()); + + // Make test groups. + $group1 = $generator->create_group(array('courseid' => $course->id)); + $group2 = $generator->create_group(array('courseid' => $course->id)); + $grouping1 = $generator->create_grouping(array('courseid' => $course->id)); + $grouping2 = $generator->create_grouping(array('courseid' => $course->id)); + groups_assign_grouping($grouping1->id, $group1->id); + groups_assign_grouping($grouping2->id, $group2->id); + + // Make page in grouping 2. + $pagegen = $generator->get_plugin_generator('mod_page'); + $page = $pagegen->create_instance(array('course' => $course->id, + 'groupingid' => $grouping2->id, 'availability' => + '{"op":"|","show":true,"c":[{"type":"grouping","activity":true}]}')); + + // Assign students to groups as follows (teacher is not in a group): + // 0: no groups. + // 1: in group 1/grouping 1. + // 2: in group 2/grouping 2. + groups_add_member($group1, $students[1]); + groups_add_member($group2, $students[2]); + + // Test specific grouping. + $cond = new condition((object)array('id' => (int)$grouping1->id)); + $result = array_keys($cond->filter_user_list($allusers, false, $info, $checker)); + ksort($result); + $this->assertEquals(array($teacher->id, $students[1]->id), $result); + $result = array_keys($cond->filter_user_list($allusers, true, $info, $checker)); + ksort($result); + $this->assertEquals(array($teacher->id, $students[0]->id, $students[2]->id), $result); + + // Test course-module grouping. + $modinfo = get_fast_modinfo($course); + $cm = $modinfo->get_cm($page->cmid); + $info = new \core_availability\info_module($cm); + $result = array_keys($info->filter_user_list($allusers, $course)); + $this->assertEquals(array($teacher->id, $students[2]->id), $result); + } +} diff --git a/availability/condition/grouping/version.php b/availability/condition/grouping/version.php new file mode 100644 index 00000000000..7cd2c1fb129 --- /dev/null +++ b/availability/condition/grouping/version.php @@ -0,0 +1,29 @@ +. + +/** + * Version info. + * + * @package availability_grouping + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->version = 2014022800; +$plugin->requires = 2014040401; +$plugin->component = 'availability_grouping'; diff --git a/availability/condition/grouping/yui/src/form/build.json b/availability/condition/grouping/yui/src/form/build.json new file mode 100644 index 00000000000..4e08da11259 --- /dev/null +++ b/availability/condition/grouping/yui/src/form/build.json @@ -0,0 +1,10 @@ +{ + "name": "moodle-availability_grouping-form", + "builds": { + "moodle-availability_grouping-form": { + "jsfiles": [ + "form.js" + ] + } + } +} diff --git a/availability/condition/grouping/yui/src/form/js/form.js b/availability/condition/grouping/yui/src/form/js/form.js new file mode 100644 index 00000000000..397000e3ce7 --- /dev/null +++ b/availability/condition/grouping/yui/src/form/js/form.js @@ -0,0 +1,82 @@ +/** + * JavaScript for form editing grouping conditions. + * + * @module moodle-availability_grouping-form + */ +M.availability_grouping = M.availability_grouping || {}; + +/** + * @class M.availability_grouping.form + * @extends M.core_availability.plugin + */ +M.availability_grouping.form = Y.Object(M.core_availability.plugin); + +/** + * Groupings available for selection (alphabetical order). + * + * @property groupings + * @type Array + */ +M.availability_grouping.form.groupings = null; + +/** + * Initialises this plugin. + * + * @method initInner + * @param {Array} groupings Array of objects containing groupingid => name + */ +M.availability_grouping.form.initInner = function(groupings) { + this.groupings = groupings; +}; + +M.availability_grouping.form.getNode = function(json) { + // Create HTML structure. + var strings = M.str.availability_grouping; + var html = ''; + var node = Y.Node.create('' + html + ''); + + // Set initial value if specified. + if (json.id !== undefined && + node.one('select[name=id] > option[value=' + json.id + ']')) { + node.one('select[name=id]').set('value', json.id); + } + + // Add event handlers (first time only). + if (!M.availability_grouping.form.addedEvents) { + M.availability_grouping.form.addedEvents = true; + var root = Y.one('#fitem_id_availabilityconditionsjson'); + root.delegate('change', function() { + // Just update the form fields. + M.core_availability.form.update(); + }, '.availability_grouping select'); + } + + return node; +}; + +M.availability_grouping.form.fillValue = function(value, node) { + var selected = node.one('select[name=id]').get('value'); + if (selected === 'choose') { + value.id = 'choose'; + } else { + value.id = parseInt(selected, 10); + } +}; + +M.availability_grouping.form.fillErrors = function(errors, node) { + var value = {}; + this.fillValue(value, node); + + // Check grouping item id. + if (value.id === 'choose') { + errors.push('availability_grouping:error_selectgrouping'); + } +}; diff --git a/availability/condition/grouping/yui/src/form/meta/form.json b/availability/condition/grouping/yui/src/form/meta/form.json new file mode 100644 index 00000000000..2e0bb8f07bd --- /dev/null +++ b/availability/condition/grouping/yui/src/form/meta/form.json @@ -0,0 +1,10 @@ +{ + "moodle-availability_grouping-form": { + "requires": [ + "base", + "node", + "event", + "moodle-core_availability-form" + ] + } +} diff --git a/availability/condition/profile/classes/condition.php b/availability/condition/profile/classes/condition.php new file mode 100644 index 00000000000..4d22e4e507c --- /dev/null +++ b/availability/condition/profile/classes/condition.php @@ -0,0 +1,455 @@ +. + +/** + * User profile field condition. + * + * @package availability_profile + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace availability_profile; + +defined('MOODLE_INTERNAL') || die(); + +/** + * User profile field condition. + * + * @package availability_profile + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class condition extends \core_availability\condition { + /** @var string Operator: field contains value */ + const OP_CONTAINS = 'contains'; + + /** @var string Operator: field does not contain value */ + const OP_DOES_NOT_CONTAIN = 'doesnotcontain'; + + /** @var string Operator: field equals value */ + const OP_IS_EQUAL_TO = 'isequalto'; + + /** @var string Operator: field starts with value */ + const OP_STARTS_WITH = 'startswith'; + + /** @var string Operator: field ends with value */ + const OP_ENDS_WITH = 'endswith'; + + /** @var string Operator: field is empty */ + const OP_IS_EMPTY = 'isempty'; + + /** @var string Operator: field is not empty */ + const OP_IS_NOT_EMPTY = 'isnotempty'; + + /** @var array|null Array of custom profile fields (static cache within request) */ + protected static $customprofilefields = null; + + /** @var string Field name (for standard fields) or '' if custom field */ + protected $standardfield = ''; + + /** @var int Field name (for custom fields) or '' if standard field */ + protected $customfield = ''; + + /** @var string Operator type (OP_xx constant) */ + protected $operator; + + /** @var string Expected value for field */ + protected $value = ''; + + /** + * Constructor. + * + * @param stdClass $structure Data structure from JSON decode + * @throws coding_exception If invalid data structure. + */ + public function __construct($structure) { + // Get operator. + if (isset($structure->op) && in_array($structure->op, array(self::OP_CONTAINS, + self::OP_DOES_NOT_CONTAIN, self::OP_IS_EQUAL_TO, self::OP_STARTS_WITH, + self::OP_ENDS_WITH, self::OP_IS_EMPTY, self::OP_IS_NOT_EMPTY), true)) { + $this->operator = $structure->op; + } else { + throw new \coding_exception('Missing or invalid ->op for profile condition'); + } + + // For operators other than the empty/not empty ones, require value. + switch($this->operator) { + case self::OP_IS_EMPTY: + case self::OP_IS_NOT_EMPTY: + if (isset($structure->v)) { + throw new \coding_exception('Unexpected ->v for non-value operator'); + } + break; + default: + if (isset($structure->v) && is_string($structure->v)) { + $this->value = $structure->v; + } else { + throw new \coding_exception('Missing or invalid ->v for profile condition'); + } + break; + } + + // Get field type. + if (property_exists($structure, 'sf')) { + if (property_exists($structure, 'cf')) { + throw new \coding_exception('Both ->sf and ->cf for profile condition'); + } + if (is_string($structure->sf)) { + $this->standardfield = $structure->sf; + } else { + throw new \coding_exception('Invalid ->sf for profile condition'); + } + } else if (property_exists($structure, 'cf')) { + if (is_string($structure->cf)) { + $this->customfield = $structure->cf; + } else { + throw new \coding_exception('Invalid ->cf for profile condition'); + } + } else { + throw new \coding_exception('Missing ->sf or ->cf for profile condition'); + } + } + + public function save() { + $result = (object)array('type' => 'profile', 'op' => $this->operator); + if ($this->customfield) { + $result->cf = $this->customfield; + } else { + $result->sf = $this->standardfield; + } + switch($this->operator) { + case self::OP_IS_EMPTY: + case self::OP_IS_NOT_EMPTY: + break; + default: + $result->v = $this->value; + break; + } + return $result; + } + + public function is_available($not, \core_availability\info $info, $grabthelot, $userid) { + $uservalue = $this->get_cached_user_profile_field($userid); + $allow = self::is_field_condition_met($this->operator, $uservalue, $this->value); + if ($not) { + $allow = !$allow; + } + return $allow; + } + + public function get_description($full, $not, \core_availability\info $info) { + $course = $info->get_course(); + // Display the fieldname into current lang. + if ($this->customfield) { + // Is a custom profile field (will use multilang). + $customfields = self::get_custom_profile_fields(); + if (array_key_exists($this->customfield, $customfields)) { + $translatedfieldname = $customfields[$this->customfield]->name; + } else { + $translatedfieldname = get_string('missing', 'availability_profile', + $this->customfield); + } + } else { + $translatedfieldname = get_user_field_name($this->standardfield); + } + $context = \context_course::instance($course->id); + $a = new \stdClass(); + $a->field = format_string($translatedfieldname, true, array('context' => $context)); + $a->value = s($this->value); + if ($not) { + // When doing NOT strings, we replace the operator with its inverse. + // Some of them don't have inverses, so for those we use a new + // identifier which is only used for this lang string. + switch($this->operator) { + case self::OP_CONTAINS: + $opname = self::OP_DOES_NOT_CONTAIN; + break; + case self::OP_DOES_NOT_CONTAIN: + $opname = self::OP_CONTAINS; + break; + case self::OP_ENDS_WITH: + $opname = 'notendswith'; + break; + case self::OP_IS_EMPTY: + $opname = self::OP_IS_NOT_EMPTY; + break; + case self::OP_IS_EQUAL_TO: + $opname = 'notisequalto'; + break; + case self::OP_IS_NOT_EMPTY: + $opname = self::OP_IS_EMPTY; + break; + case self::OP_STARTS_WITH: + $opname = 'notstartswith'; + break; + default: + throw new \coding_exception('Unexpected operator: ' . $this->operator); + } + } else { + $opname = $this->operator; + } + return get_string('requires_' . $opname, 'availability_profile', $a); + } + + protected function get_debug_string() { + if ($this->customfield) { + $out = '*' . $this->customfield; + } else { + $out = $this->standardfield; + } + $out .= ' ' . $this->operator; + switch($this->operator) { + case self::OP_IS_EMPTY: + case self::OP_IS_NOT_EMPTY: + break; + default: + $out .= ' ' . $this->value; + break; + } + return $out; + } + + /** + * Returns true if a field meets the required conditions, false otherwise. + * + * @param string $operator the requirement/condition + * @param string $uservalue the user's value + * @param string $value the value required + * @return boolean True if conditions are met + */ + protected static function is_field_condition_met($operator, $uservalue, $value) { + if ($uservalue === false) { + // If the user value is false this is an instant fail. + // All user values come from the database as either data or the default. + // They will always be a string. + return false; + } + $fieldconditionmet = true; + // Just to be doubly sure it is a string. + $uservalue = (string)$uservalue; + switch($operator) { + case self::OP_CONTAINS: + $pos = strpos($uservalue, $value); + if ($pos === false) { + $fieldconditionmet = false; + } + break; + case self::OP_DOES_NOT_CONTAIN: + if (!empty($value)) { + $pos = strpos($uservalue, $value); + if ($pos !== false) { + $fieldconditionmet = false; + } + } + break; + case self::OP_IS_EQUAL_TO: + if ($value !== $uservalue) { + $fieldconditionmet = false; + } + break; + case self::OP_STARTS_WITH: + $length = strlen($value); + if ((substr($uservalue, 0, $length) !== $value)) { + $fieldconditionmet = false; + } + break; + case self::OP_ENDS_WITH: + $length = strlen($value); + $start = $length * -1; + if (substr($uservalue, $start) !== $value) { + $fieldconditionmet = false; + } + break; + case self::OP_IS_EMPTY: + if (!empty($uservalue)) { + $fieldconditionmet = false; + } + break; + case self::OP_IS_NOT_EMPTY: + if (empty($uservalue)) { + $fieldconditionmet = false; + } + break; + } + return $fieldconditionmet; + } + + /** + * Gets data about custom profile fields. Cached statically in current + * request. + * + * @return array Array of records indexed by shortname + */ + public static function get_custom_profile_fields() { + global $DB; + + if (self::$customprofilefields === null) { + self::$customprofilefields = $DB->get_records('user_info_field', null, + 'id ASC', 'shortname, id, name, defaultdata'); + } + return self::$customprofilefields; + } + + /** + * Wipes the static cache (for use in unit tests). + */ + public static function wipe_static_cache() { + self::$customprofilefields = null; + } + + /** + * Return the value for a user's profile field + * + * @param int $userid User ID + * @return string|bool Value, or false if user does not have a value for this field + */ + protected function get_cached_user_profile_field($userid) { + global $USER, $DB, $CFG; + $iscurrentuser = $USER->id == $userid; + if (isguestuser($userid) || ($iscurrentuser && !isloggedin())) { + // Must be logged in and can't be the guest. + return false; + } + + // Custom profile fields will be numeric, there are no numeric standard profile fields so this is not a problem. + $iscustomprofilefield = $this->customfield ? true : false; + if ($iscustomprofilefield) { + // As its a custom profile field we need to map the id back to the actual field. + // We'll also preload all of the other custom profile fields just in case and ensure we have the + // default value available as well. + if (!array_key_exists($this->customfield, self::get_custom_profile_fields())) { + // No such field exists. + // This shouldn't normally happen but occur if things go wrong when deleting a custom profile field + // or when restoring a backup of a course with user profile field conditions. + return false; + } + $field = $this->customfield; + } else { + $field = $this->standardfield; + } + + // If its the current user than most likely we will be able to get this information from $USER. + // If its a regular profile field then it should already be available, if not then we have a mega problem. + // If its a custom profile field then it should be available but may not be. If it is then we use the value + // available, otherwise we load all custom profile fields into a temp object and refer to that. + // Noting its not going be great for performance if we have to use the temp object as it involves loading the + // custom profile field API and classes. + if ($iscurrentuser) { + if (!$iscustomprofilefield) { + if (property_exists($USER, $field)) { + return $USER->{$field}; + } else { + // Unknown user field. This should not happen. + throw new \coding_exception('Requested user profile field does not exist'); + } + } + // Checking if the custom profile fields are already available. + if (!isset($USER->profile)) { + // Drat! they're not. We need to use a temp object and load them. + // We don't use $USER as the profile fields are loaded into the object. + $user = new \stdClass; + $user->id = $USER->id; + // This should ALWAYS be set, but just in case we check. + require_once($CFG->dirroot . '/user/profile/lib.php'); + profile_load_custom_fields($user); + if (array_key_exists($field, $user->profile)) { + return $user->profile[$field]; + } + } else if (array_key_exists($field, $USER->profile)) { + // Hurrah they're available, this is easy. + return $USER->profile[$field]; + } + // The profile field doesn't exist. + return false; + } else { + // Loading for another user. + if ($iscustomprofilefield) { + // Fetch the data for the field. Noting we keep this query simple so that Database caching takes care of performance + // for us (this will likely be hit again). + // We are able to do this because we've already pre-loaded the custom fields. + $data = $DB->get_field('user_info_data', 'data', array('userid' => $userid, + 'fieldid' => self::$customprofilefields[$field]->id), IGNORE_MISSING); + // If we have data return that, otherwise return the default. + if ($data !== false) { + return $data; + } else { + return self::$customprofilefields[$field]->defaultdata; + } + } else { + // Its a standard field, retrieve it from the user. + return $DB->get_field('user', $field, array('id' => $userid), MUST_EXIST); + } + } + return false; + } + + public function is_applied_to_user_lists() { + // Profile conditions are assumed to be 'permanent', so they affect the + // display of user lists for activities. + return true; + } + + public function filter_user_list(array $users, $not, \core_availability\info $info, + \core_availability\capability_checker $checker) { + global $CFG, $DB; + + // Get all users from the list who match the condition. + list ($sql, $params) = $DB->get_in_or_equal(array_keys($users)); + + if ($this->customfield) { + $customfields = self::get_custom_profile_fields(); + if (!array_key_exists($this->customfield, $customfields)) { + // If the field isn't found, nobody matches. + return array(); + } + $customfield = $customfields[$this->customfield]; + + // Fetch custom field value for all users. + $values = $DB->get_records_select('user_info_data', 'fieldid = ? AND userid ' . $sql, + array_merge(array($customfield->id), $params), + '', 'userid, data'); + $valuefield = 'data'; + $default = $customfield->defaultdata; + } else { + $values = $DB->get_records_select('user', 'id ' . $sql, $params, + '', 'id, '. $this->standardfield); + $valuefield = $this->standardfield; + $default = ''; + } + + // Filter the user list. + $result = array(); + foreach ($users as $id => $user) { + // Get value for user. + if (array_key_exists($id, $values)) { + $value = $values[$id]->{$valuefield}; + } else { + $value = $default; + } + + // Check value. + $allow = $this->is_field_condition_met($this->operator, $value, $this->value); + if ($not) { + $allow = !$allow; + } + if ($allow) { + $result[$id] = $user; + } + } + return $result; + } +} diff --git a/availability/condition/profile/classes/frontend.php b/availability/condition/profile/classes/frontend.php new file mode 100644 index 00000000000..373c9a1984a --- /dev/null +++ b/availability/condition/profile/classes/frontend.php @@ -0,0 +1,79 @@ +. + +/** + * Front-end class. + * + * @package availability_profile + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace availability_profile; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Front-end class. + * + * @package availability_profile + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class frontend extends \core_availability\frontend { + protected function get_javascript_strings() { + return array('op_contains', 'op_doesnotcontain', 'op_endswith', 'op_isempty', + 'op_isequalto', 'op_isnotempty', 'op_startswith', 'conditiontitle', + 'label_operator', 'label_value'); + } + + protected function get_javascript_init_params($course, \cm_info $cm = null, + \section_info $section = null) { + // Standard user fields. + $standardfields = array( + 'firstname' => get_user_field_name('firstname'), + 'lastname' => get_user_field_name('lastname'), + 'email' => get_user_field_name('email'), + 'city' => get_user_field_name('city'), + 'country' => get_user_field_name('country'), + 'url' => get_user_field_name('url'), + 'icq' => get_user_field_name('icq'), + 'skype' => get_user_field_name('skype'), + 'aim' => get_user_field_name('aim'), + 'yahoo' => get_user_field_name('yahoo'), + 'msn' => get_user_field_name('msn'), + 'idnumber' => get_user_field_name('idnumber'), + 'institution' => get_user_field_name('institution'), + 'department' => get_user_field_name('department'), + 'phone1' => get_user_field_name('phone1'), + 'phone2' => get_user_field_name('phone2'), + 'address' => get_user_field_name('address') + ); + \core_collator::asort($standardfields); + + // Custom fields. + $customfields = array(); + $options = array('context' => \context_course::instance($course->id)); + foreach (condition::get_custom_profile_fields() as $field) { + $customfields[$field->shortname] = format_string($field->name, true, $options); + } + \core_collator::asort($customfields); + + // Make arrays into JavaScript format (non-associative, ordered) and return. + return array(self::convert_associative_array_for_js($standardfields, 'field', 'display'), + self::convert_associative_array_for_js($customfields, 'field', 'display')); + } +} diff --git a/availability/condition/profile/lang/en/availability_profile.php b/availability/condition/profile/lang/en/availability_profile.php new file mode 100644 index 00000000000..104eb310bd4 --- /dev/null +++ b/availability/condition/profile/lang/en/availability_profile.php @@ -0,0 +1,50 @@ +. + +/** + * Language strings. + * + * @package availability_profile + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['conditiontitle'] = 'User profile field'; +$string['description'] = 'Control access based on fields within the student’s profile.'; +$string['error_selectfield'] = 'You must select a profile field.'; +$string['error_setvalue'] = 'You must type a value.'; +$string['label_operator'] = 'Method of comparison'; +$string['label_value'] = 'Value to compare against'; +$string['pluginname'] = 'Restriction by profile'; +$string['requires_contains'] = 'Your {$a->field} contains {$a->value}'; +$string['requires_doesnotcontain'] = 'Your {$a->field} does not contain {$a->value}'; +$string['requires_endswith'] = 'Your {$a->field} ends with {$a->value}'; +$string['requires_isempty'] = 'Your {$a->field} is empty'; +$string['requires_isequalto'] = 'Your {$a->field} is {$a->value}'; +$string['requires_isnotempty'] = 'Your {$a->field} is not empty'; +$string['requires_notendswith'] = 'Your {$a->field} does not end with {$a->value}'; +$string['requires_notisequalto'] = 'Your {$a->field} is not {$a->value}'; +$string['requires_notstartswith'] = 'Your {$a->field} does not start with {$a->value}'; +$string['requires_startswith'] = 'Your {$a->field} starts with {$a->value}'; +$string['missing'] = '(Missing custom field: {$a})'; +$string['title'] = 'User profile'; +$string['op_contains'] = 'contains'; +$string['op_doesnotcontain'] = 'doesn’t contain'; +$string['op_endswith'] = 'ends with'; +$string['op_isempty'] = 'is empty'; +$string['op_isequalto'] = 'is equal to'; +$string['op_isnotempty'] = 'is not empty'; +$string['op_startswith'] = 'starts with'; diff --git a/availability/condition/profile/tests/behat/availability_profile.feature b/availability/condition/profile/tests/behat/availability_profile.feature new file mode 100644 index 00000000000..e4335ed05dc --- /dev/null +++ b/availability/condition/profile/tests/behat/availability_profile.feature @@ -0,0 +1,66 @@ +@availability @availability_profile +Feature: availability_profile + In order to control student access to activities + As a teacher + I need to set profile conditions which prevent student access + + Background: + Given the following "courses" exist: + | fullname | shortname | format | enablecompletion | + | Course 1 | C1 | topics | 1 | + And the following "users" exist: + | username | email | + | teacher1 | t@example.org | + | student1 | s@example.org | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And I log in as "admin" + And I set the following administration settings values: + | Enable conditional access | 1 | + And I log out + + @javascript + Scenario: Test condition + # Basic setup. + Given I log in as "teacher1" + And I follow "Course 1" + And I turn editing mode on + + # Add + And I add a "Page" to section "1" + And I set the following fields to these values: + | Name | P1 | + | Description | x | + | Page content | x | + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "User profile" "button" + And I set the field "User profile field" to "Email address" + And I set the field "Value to compare against" to "s@example.org" + And I click on ".availability-item .availability-eye img" "css_element" + And I click on "Save and return to course" "button" + + # Add + And I add a "Page" to section "2" + And I set the following fields to these values: + | Name | P2 | + | Description | x | + | Page content | x | + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "User profile" "button" + And I set the field "User profile field" to "Email address" + And I set the field "Value to compare against" to "q@example.org" + And I click on ".availability-item .availability-eye img" "css_element" + And I click on "Save and return to course" "button" + + # Log back in as student. + When I log out + And I log in as "student1" + And I follow "Course 1" + + # I see P1 but not P2. + Then I should see "P1" in the "region-main" "region" + And I should not see "P2" in the "region-main" "region" diff --git a/availability/condition/profile/tests/condition_test.php b/availability/condition/profile/tests/condition_test.php new file mode 100644 index 00000000000..d81d9518667 --- /dev/null +++ b/availability/condition/profile/tests/condition_test.php @@ -0,0 +1,421 @@ +. + +/** + * Unit tests for the user profile condition. + * + * @package availability_profile + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +use availability_profile\condition; + +/** + * Unit tests for the user profile condition. + * + * @package availability_profile + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class availability_profile_condition_testcase extends advanced_testcase { + /** @var profile_define_text Profile field for testing */ + protected $profilefield; + + /** @var array Array of user IDs for whome we already set the profile field */ + protected $setusers = array(); + + public function setUp() { + global $DB, $CFG; + + $this->resetAfterTest(); + + // Add a custom profile field type. The API for doing this is indescribably + // horrid and tightly intertwined with the form UI, so it's best to add + // it directly in database. + $DB->insert_record('user_info_field', array( + 'shortname' => 'frogtype', 'name' => 'Type of frog', 'categoryid' => 1, + 'datatype' => 'text')); + $this->profilefield = $DB->get_record('user_info_field', + array('shortname' => 'frogtype')); + + // Clear static cache. + \availability_profile\condition::wipe_static_cache(); + + // Load the mock info class so that it can be used. + require_once($CFG->dirroot . '/availability/tests/fixtures/mock_info.php'); + } + + /** + * Tests constructing and using date condition as part of tree. + */ + public function test_in_tree() { + global $USER; + + $this->setAdminUser(); + + $info = new \core_availability\mock_info(); + + $structure = (object)array('op' => '|', 'show' => true, 'c' => array( + (object)array('type' => 'profile', + 'op' => condition::OP_IS_EQUAL_TO, + 'cf' => 'frogtype', 'v' => 'tree'))); + $tree = new \core_availability\tree($structure); + + // Initial check (user does not have custom field). + $result = $tree->check_available(false, $info, true, $USER->id); + $this->assertFalse($result->is_available()); + + // Set field. + $this->set_field($USER->id, 'tree'); + + // Now it's true! + $result = $tree->check_available(false, $info, true, $USER->id); + $this->assertTrue($result->is_available()); + } + + /** + * Tests the constructor including error conditions. Also tests the + * string conversion feature (intended for debugging only). + */ + public function test_constructor() { + // No parameters. + $structure = new stdClass(); + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing or invalid ->op', $e->getMessage()); + } + + // Invalid op. + $structure->op = 'isklingonfor'; + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing or invalid ->op', $e->getMessage()); + } + + // Missing value. + $structure->op = condition::OP_IS_EQUAL_TO; + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing or invalid ->v', $e->getMessage()); + } + + // Invalid value (not string). + $structure->v = false; + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing or invalid ->v', $e->getMessage()); + } + + // Unexpected value. + $structure->op = condition::OP_IS_EMPTY; + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Unexpected ->v', $e->getMessage()); + } + + // Missing field. + $structure->op = condition::OP_IS_EQUAL_TO; + $structure->v = 'flying'; + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Missing ->sf or ->cf', $e->getMessage()); + } + + // Invalid field (not string). + $structure->sf = 42; + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Invalid ->sf', $e->getMessage()); + } + + // Both fields. + $structure->sf = 'department'; + $structure->cf = 'frogtype'; + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Both ->sf and ->cf', $e->getMessage()); + } + + // Invalid ->cf field (not string). + unset($structure->sf); + $structure->cf = false; + try { + $cond = new condition($structure); + $this->fail(); + } catch (coding_exception $e) { + $this->assertContains('Invalid ->cf', $e->getMessage()); + } + + // Valid examples (checks values are correctly included). + $structure->cf = 'frogtype'; + $cond = new condition($structure); + $this->assertEquals('{profile:*frogtype isequalto flying}', (string)$cond); + + unset($structure->v); + $structure->op = condition::OP_IS_EMPTY; + $cond = new condition($structure); + $this->assertEquals('{profile:*frogtype isempty}', (string)$cond); + + unset($structure->cf); + $structure->sf = 'department'; + $cond = new condition($structure); + $this->assertEquals('{profile:department isempty}', (string)$cond); + } + + /** + * Tests the save() function. + */ + public function test_save() { + $structure = (object)array('cf' => 'frogtype', 'op' => condition::OP_IS_EMPTY); + $cond = new condition($structure); + $structure->type = 'profile'; + $this->assertEquals($structure, $cond->save()); + + $structure = (object)array('cf' => 'frogtype', 'op' => condition::OP_ENDS_WITH, + 'v' => 'bouncy'); + $cond = new condition($structure); + $structure->type = 'profile'; + $this->assertEquals($structure, $cond->save()); + } + + /** + * Tests the is_available function. There is no separate test for + * get_full_information because that function is called from is_available + * and we test its values here. + */ + public function test_is_available() { + global $USER, $SITE, $DB; + $this->setAdminUser(); + $info = new \core_availability\mock_info(); + + // Prepare to test with all operators against custom field using all + // combinations of NOT and true/false states.. + $information = 'x'; + $structure = (object)array('cf' => 'frogtype'); + + $structure->op = condition::OP_IS_NOT_EMPTY; + $cond = new condition($structure); + $this->assert_is_available_result(false, '~Type of frog.*is not empty~', + $cond, $info, $USER->id); + $this->set_field($USER->id, 'poison dart'); + $this->assert_is_available_result(true, '~Type of frog.*is empty~', + $cond, $info, $USER->id); + + $structure->op = condition::OP_IS_EMPTY; + $cond = new condition($structure); + $this->assert_is_available_result(false, '~.*Type of frog.*is empty~', + $cond, $info, $USER->id); + $this->set_field($USER->id, null); + $this->assert_is_available_result(true, '~.*Type of frog.*is not empty~', + $cond, $info, $USER->id); + $this->set_field($USER->id, ''); + $this->assert_is_available_result(true, '~.*Type of frog.*is not empty~', + $cond, $info, $USER->id); + + $structure->op = condition::OP_CONTAINS; + $structure->v = 'llf'; + $cond = new condition($structure); + $this->assert_is_available_result(false, '~Type of frog.*contains.*llf~', + $cond, $info, $USER->id); + $this->set_field($USER->id, 'bullfrog'); + $this->assert_is_available_result(true, '~Type of frog.*does not contain.*llf~', + $cond, $info, $USER->id); + + $structure->op = condition::OP_DOES_NOT_CONTAIN; + $cond = new condition($structure); + $this->assert_is_available_result(false, '~Type of frog.*does not contain.*llf~', + $cond, $info, $USER->id); + $this->set_field($USER->id, 'goliath'); + $this->assert_is_available_result(true, '~Type of frog.*contains.*llf~', + $cond, $info, $USER->id); + + $structure->op = condition::OP_IS_EQUAL_TO; + $structure->v = 'Kermit'; + $cond = new condition($structure); + $this->assert_is_available_result(false, '~Type of frog.*is <.*Kermit~', + $cond, $info, $USER->id); + $this->set_field($USER->id, 'Kermit'); + $this->assert_is_available_result(true, '~Type of frog.*is not.*Kermit~', + $cond, $info, $USER->id); + + $structure->op = condition::OP_STARTS_WITH; + $structure->v = 'Kerm'; + $cond = new condition($structure); + $this->assert_is_available_result(true, '~Type of frog.*does not start.*Kerm~', + $cond, $info, $USER->id); + $this->set_field($USER->id, 'Keroppi'); + $this->assert_is_available_result(false, '~Type of frog.*starts.*Kerm~', + $cond, $info, $USER->id); + + $structure->op = condition::OP_ENDS_WITH; + $structure->v = 'ppi'; + $cond = new condition($structure); + $this->assert_is_available_result(true, '~Type of frog.*does not end.*ppi~', + $cond, $info, $USER->id); + $this->set_field($USER->id, 'Kermit'); + $this->assert_is_available_result(false, '~Type of frog.*ends.*ppi~', + $cond, $info, $USER->id); + + // Also test is_available for a different (not current) user. + $generator = $this->getDataGenerator(); + $user = $generator->create_user(); + $structure->op = condition::OP_CONTAINS; + $structure->v = 'rne'; + $cond = new condition($structure); + $this->assertFalse($cond->is_available(false, $info, true, $user->id)); + $this->set_field($user->id, 'horned'); + $this->assertTrue($cond->is_available(false, $info, true, $user->id)); + + // Now check with a standard field (department). + $structure = (object)array('op' => condition::OP_IS_EQUAL_TO, + 'sf' => 'department', 'v' => 'Cheese Studies'); + $cond = new condition($structure); + $this->assertFalse($cond->is_available(false, $info, true, $USER->id)); + $this->assertFalse($cond->is_available(false, $info, true, $user->id)); + + // Check the message (should be using lang string with capital, which + // is evidence that it called the right function to get the name). + $information = $cond->get_description(false, false, $info); + $this->assertRegExp('~Department~', $information); + + // Set the field to true for both users and retry. + $DB->set_field('user', 'department', 'Cheese Studies', array('id' => $user->id)); + $USER->department = 'Cheese Studies'; + $this->assertTrue($cond->is_available(false, $info, true, $USER->id)); + $this->assertTrue($cond->is_available(false, $info, true, $user->id)); + } + + /** + * Sets the custom profile field used for testing. + * + * @param int $userid User id + * @param string|null $value Field value or null to clear + */ + protected function set_field($userid, $value) { + global $DB, $USER; + + $alreadyset = array_key_exists($userid, $this->setusers); + if (is_null($value)) { + $DB->delete_records('user_info_data', + array('userid' => $userid, 'fieldid' => $this->profilefield->id)); + unset($this->setusers[$userid]); + } else if ($alreadyset) { + $DB->set_field('user_info_data', 'data', $value, + array('userid' => $userid, 'fieldid' => $this->profilefield->id)); + } else { + $DB->insert_record('user_info_data', array('userid' => $userid, + 'fieldid' => $this->profilefield->id, 'data' => $value)); + $this->setusers[$userid] = true; + } + } + + /** + * Checks the result of is_available. This function is to save duplicated + * code; it does two checks (the normal is_available with $not set to true + * and set to false). Whichever result is expected to be true, it checks + * $information ends up as empty string for that one, and as a regex match + * for another one. + * + * @param bool $yes If the positive test is expected to return true + * @param string $failpattern Regex pattern to match text when it returns false + * @param condition $cond Condition + * @param \core_availability\info $info Information about current context + * @param int $userid User id + */ + protected function assert_is_available_result($yes, $failpattern, condition $cond, + \core_availability\info $info, $userid) { + // Positive (normal) test. + $this->assertEquals($yes, $cond->is_available(false, $info, true, $userid), + 'Failed checking normal (positive) result'); + if (!$yes) { + $information = $cond->get_description(false, false, $info); + $this->assertRegExp($failpattern, $information); + } + + // Negative (NOT) test. + $this->assertEquals(!$yes, $cond->is_available(true, $info, true, $userid), + 'Failed checking NOT (negative) result'); + if ($yes) { + $information = $cond->get_description(false, true, $info); + $this->assertRegExp($failpattern, $information); + } + } + + /** + * Tests the filter_users (bulk checking) function. + */ + public function test_filter_users() { + global $DB, $CFG; + $this->resetAfterTest(); + $CFG->enableavailability = true; + + // Erase static cache before test. + condition::wipe_static_cache(); + + // Make a test course and some users. + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + $student1 = $generator->create_user(array('institution' => 'Unseen University')); + $student2 = $generator->create_user(array('institution' => 'Hogwarts')); + $student3 = $generator->create_user(array('institution' => 'Unseen University')); + $allusers = array(); + foreach (array($student1, $student2, $student3) as $student) { + $generator->enrol_user($student->id, $course->id); + $allusers[$student->id] = $student; + } + $this->set_field($student1->id, 'poison dart'); + $this->set_field($student2->id, 'poison dart'); + $info = new \core_availability\mock_info($course); + $checker = new \core_availability\capability_checker($info->get_context()); + + // Test standard field condition (positive and negative). + $cond = new condition((object)array('sf' => 'institution', 'op' => 'contains', 'v' => 'Unseen')); + $result = array_keys($cond->filter_user_list($allusers, false, $info, $checker)); + ksort($result); + $this->assertEquals(array($student1->id, $student3->id), $result); + $result = array_keys($cond->filter_user_list($allusers, true, $info, $checker)); + ksort($result); + $this->assertEquals(array($student2->id), $result); + + // Test custom field condition. + $cond = new condition((object)array('cf' => 'frogtype', 'op' => 'contains', 'v' => 'poison')); + $result = array_keys($cond->filter_user_list($allusers, false, $info, $checker)); + ksort($result); + $this->assertEquals(array($student1->id, $student2->id), $result); + $result = array_keys($cond->filter_user_list($allusers, true, $info, $checker)); + ksort($result); + $this->assertEquals(array($student3->id), $result); + } +} diff --git a/availability/condition/profile/version.php b/availability/condition/profile/version.php new file mode 100644 index 00000000000..bec0fba9af1 --- /dev/null +++ b/availability/condition/profile/version.php @@ -0,0 +1,29 @@ +. + +/** + * Version info. + * + * @package availability_profile + * @copyright 2014 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->version = 2014022700; +$plugin->requires = 2014040401; +$plugin->component = 'availability_profile'; diff --git a/availability/condition/profile/yui/src/form/build.json b/availability/condition/profile/yui/src/form/build.json new file mode 100644 index 00000000000..edadb41d49f --- /dev/null +++ b/availability/condition/profile/yui/src/form/build.json @@ -0,0 +1,10 @@ +{ + "name": "moodle-availability_profile-form", + "builds": { + "moodle-availability_profile-form": { + "jsfiles": [ + "form.js" + ] + } + } +} diff --git a/availability/condition/profile/yui/src/form/js/form.js b/availability/condition/profile/yui/src/form/js/form.js new file mode 100644 index 00000000000..ca3f3be5c2b --- /dev/null +++ b/availability/condition/profile/yui/src/form/js/form.js @@ -0,0 +1,130 @@ +/** + * JavaScript for form editing profile conditions. + * + * @module moodle-availability_profile-form + */ +M.availability_profile = M.availability_profile || {}; + +/** + * @class M.availability_profile.form + * @extends M.core_availability.plugin + */ +M.availability_profile.form = Y.Object(M.core_availability.plugin); + +/** + * Groupings available for selection (alphabetical order). + * + * @property profiles + * @type Array + */ +M.availability_profile.form.profiles = null; + +/** + * Initialises this plugin. + * + * @method initInner + * @param {Array} standardFields Array of objects with .field, .display + * @param {Array} customFields Array of objects with .field, .display + */ +M.availability_profile.form.initInner = function(standardFields, customFields) { + this.standardFields = standardFields; + this.customFields = customFields; +}; + +M.availability_profile.form.getNode = function(json) { + // Create HTML structure. + var strings = M.str.availability_profile; + var html = ' '; + var node = Y.Node.create('' + html + ''); + + // Set initial values if specified. + if (json.sf !== undefined && + node.one('select[name=field] > option[value=sf_' + json.sf + ']')) { + node.one('select[name=field]').set('value', 'sf_' + json.sf); + } else if (json.cf !== undefined && + node.one('select[name=field] > option[value=cf_' + json.sf + ']')) { + node.one('select[name=field]').set('value', 'cf_' + json.cf); + } + if (json.op !== undefined && + node.one('select[name=op] > option[value=' + json.op + ']')) { + node.one('select[name=op]').set('value', json.op); + } + if (json.v !== undefined) { + node.one('input').set('value', json.v); + } + + // Add event handlers (first time only). + if (!M.availability_profile.form.addedEvents) { + M.availability_profile.form.addedEvents = true; + var updateForm = function(input) { + var ancestorNode = input.ancestor('span.availability_profile'); + var op = ancestorNode.one('select[name=op]'); + var novalue = (op.get('value') === 'isempty' || op.get('value') === 'isnotempty'); + ancestorNode.one('input[name=value]').set('disabled', novalue); + M.core_availability.form.update(); + }; + var root = Y.one('#fitem_id_availabilityconditionsjson'); + root.delegate('change', function() { + updateForm(this); + }, '.availability_profile select'); + root.delegate('change', function() { + updateForm(this); + }, '.availability_profile input[name=value]'); + } + + return node; +}; + +M.availability_profile.form.fillValue = function(value, node) { + // Set field. + var field = node.one('select[name=field]').get('value'); + if (field.substr(0, 3) === 'sf_') { + value.sf = field.substr(3); + } else if (field.substr(0, 3) === 'cf_') { + value.cf = field.substr(3); + } + + // Operator and value + value.op = node.one('select[name=op]').get('value'); + var valueNode = node.one('input[name=value]'); + if (!valueNode.get('disabled')) { + value.v = valueNode.get('value'); + } +}; + +M.availability_profile.form.fillErrors = function(errors, node) { + var value = {}; + this.fillValue(value, node); + + // Check profile item id. + if (value.sf === undefined && value.cf === undefined) { + errors.push('availability_profile:error_selectfield'); + } + if (value.v !== undefined && /^\s*$/.test(value.v)) { + errors.push('availability_profile:error_setvalue'); + } +}; diff --git a/availability/condition/profile/yui/src/form/meta/form.json b/availability/condition/profile/yui/src/form/meta/form.json new file mode 100644 index 00000000000..5fe63356274 --- /dev/null +++ b/availability/condition/profile/yui/src/form/meta/form.json @@ -0,0 +1,10 @@ +{ + "moodle-availability_profile-form": { + "requires": [ + "base", + "node", + "event", + "moodle-core_availability-form" + ] + } +} diff --git a/lib/grade/grade_grade.php b/lib/grade/grade_grade.php index 9813c5620fa..8763fdd3a7a 100644 --- a/lib/grade/grade_grade.php +++ b/lib/grade/grade_grade.php @@ -771,13 +771,14 @@ class grade_grade extends grade_object { * * @param bool $deleted True if grade was actually deleted */ - function notify_changed($deleted) { + protected function notify_changed($deleted) { global $CFG; - // Inform conditionlib since it may cache the grades for conditional availability of modules or sections. - if (!empty($CFG->enableavailability)) { - require_once($CFG->libdir.'/conditionlib.php'); - condition_info_base::inform_grade_changed($this, $deleted); + // Condition code may cache the grades for conditional availability of + // modules or sections. (This code should use a hook for communication + // with plugin, but hooks are not implemented at time of writing.) + if (!empty($CFG->enableavailability) && class_exists('\availability_grade\callbacks')) { + \availability_grade\callbacks::grade_changed($this->userid); } require_once($CFG->libdir.'/completionlib.php'); diff --git a/lib/grade/grade_item.php b/lib/grade/grade_item.php index 8de9da4bdf6..d4143c079b4 100644 --- a/lib/grade/grade_item.php +++ b/lib/grade/grade_item.php @@ -2139,4 +2139,21 @@ class grade_item extends grade_object { } return parent::can_control_visibility(); } + + /** + * Used to notify the completion system (if necessary) that a user's grade + * has changed, and clear up a possible score cache. + * + * @param bool $deleted True if grade was actually deleted + */ + protected function notify_changed($deleted) { + global $CFG; + + // Condition code may cache the grades for conditional availability of + // modules or sections. (This code should use a hook for communication + // with plugin, but hooks are not implemented at time of writing.) + if (!empty($CFG->enableavailability) && class_exists('\availability_grade\callbacks')) { + \availability_grade\callbacks::grade_item_changed($this->courseid); + } + } } diff --git a/lib/grade/grade_object.php b/lib/grade/grade_object.php index cea1c181cc4..cceb178a58a 100644 --- a/lib/grade/grade_object.php +++ b/lib/grade/grade_object.php @@ -396,7 +396,7 @@ abstract class grade_object { * * @param bool $deleted */ - function notify_changed($deleted) { + protected function notify_changed($deleted) { } /**