first commit

This commit is contained in:
CHIEFSOFT\ameye
2024-09-30 18:11:26 -04:00
commit e592ca6823
27270 changed files with 5002257 additions and 0 deletions
@@ -0,0 +1,522 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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;
use cache;
use core_availability\info;
use core_availability\info_module;
use core_availability\info_section;
use stdClass;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . '/completionlib.php');
/**
* 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 previous module cm value used to calculate relative completions */
public const OPTION_PREVIOUS = -1;
/** @var int ID of module that this depends on */
protected $cmid;
/** @var array IDs of the current module and section */
protected $selfids;
/** @var int Expected completion type (one of the COMPLETE_xx constants) */
protected $expectedcompletion;
/** @var array Array of previous cmids used to calculate relative completions */
protected $modfastprevious = [];
/** @var array Array of cmids previous to each course section */
protected $sectionfastprevious = [];
/** @var array Array of modules used in these conditions for course */
protected static $modsusedincondition = [];
/**
* 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_number($structure->cm)) {
$this->cmid = (int)$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,
[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');
}
}
/**
* Saves tree data back to a structure object.
*
* @return stdClass Structure object (ready to be made into JSON format)
*/
public function save(): stdClass {
return (object) [
'type' => 'completion',
'cm' => $this->cmid,
'e' => $this->expectedcompletion,
];
}
/**
* Returns a JSON object which corresponds to a condition of this type.
*
* Intended for unit testing, as normally the JSON values are constructed
* by JavaScript code.
*
* @param int $cmid Course-module id of other activity
* @param int $expectedcompletion Expected completion value (COMPLETION_xx)
* @return stdClass Object representing condition
*/
public static function get_json(int $cmid, int $expectedcompletion): stdClass {
return (object) [
'type' => 'completion',
'cm' => (int)$cmid,
'e' => (int)$expectedcompletion,
];
}
/**
* Determines whether a particular item is currently available
* according to this availability condition.
*
* @see \core_availability\tree_node\update_after_restore
*
* @param bool $not Set true if we are inverting the condition
* @param info $info Item we're checking
* @param bool $grabthelot Performance hint: if true, caches information
* required for all course-modules, to make the front page and similar
* pages work more quickly (works only for current user)
* @param int $userid User ID to check availability for
* @return bool True if available
*/
public function is_available($not, info $info, $grabthelot, $userid): bool {
list($selfcmid, $selfsectionid) = $this->get_selfids($info);
$cmid = $this->get_cmid($info->get_course(), $selfcmid, $selfsectionid);
$modinfo = $info->get_modinfo();
$completion = new \completion_info($modinfo->get_course());
if (!array_key_exists($cmid, $modinfo->cms) || $modinfo->cms[$cmid]->deletioninprogress) {
// If the cmid cannot be found, always return false regardless
// of the condition or $not state. (Will be displayed in the
// information message.)
$allow = false;
} else {
// The completion system caches its own data so no caching needed here.
$completiondata = $completion->get_data((object)['id' => $cmid],
$grabthelot, $userid);
$allow = true;
if ($this->expectedcompletion == COMPLETION_COMPLETE) {
// Complete also allows the pass state.
switch ($completiondata->completionstate) {
case COMPLETION_COMPLETE:
case COMPLETION_COMPLETE_PASS:
break;
default:
$allow = false;
}
} else if ($this->expectedcompletion == COMPLETION_INCOMPLETE) {
// Incomplete also allows the fail state.
switch ($completiondata->completionstate) {
case COMPLETION_INCOMPLETE:
case COMPLETION_COMPLETE_FAIL:
break;
default:
$allow = false;
}
} else {
// Other values require exact match.
if ($completiondata->completionstate != $this->expectedcompletion) {
$allow = false;
}
}
if ($not) {
$allow = !$allow;
}
}
return $allow;
}
/**
* Return current item IDs (cmid and sectionid).
*
* @param info $info
* @return int[] with [0] => cmid/null, [1] => sectionid/null
*/
public function get_selfids(info $info): array {
if (isset($this->selfids)) {
return $this->selfids;
}
if ($info instanceof info_module) {
$cminfo = $info->get_course_module();
if (!empty($cminfo->id)) {
$this->selfids = [$cminfo->id, null];
return $this->selfids;
}
}
if ($info instanceof info_section) {
$section = $info->get_section();
if (!empty($section->id)) {
$this->selfids = [null, $section->id];
return $this->selfids;
}
}
return [null, null];
}
/**
* Get the cmid referenced in the access restriction.
*
* @param stdClass $course course object
* @param int|null $selfcmid current course-module ID or null
* @param int|null $selfsectionid current course-section ID or null
* @return int|null cmid or null if no referenced cm is found
*/
public function get_cmid(stdClass $course, ?int $selfcmid, ?int $selfsectionid): ?int {
if ($this->cmid > 0) {
return $this->cmid;
}
// If it's a relative completion, load fast browsing.
if ($this->cmid == self::OPTION_PREVIOUS) {
$prevcmid = $this->get_previous_cmid($course, $selfcmid, $selfsectionid);
if ($prevcmid) {
return $prevcmid;
}
}
return null;
}
/**
* Return the previous CM ID of an specific course-module or course-section.
*
* @param stdClass $course course object
* @param int|null $selfcmid course-module ID or null
* @param int|null $selfsectionid course-section ID or null
* @return int|null
*/
private function get_previous_cmid(stdClass $course, ?int $selfcmid, ?int $selfsectionid): ?int {
$this->load_course_structure($course);
if (isset($this->modfastprevious[$selfcmid])) {
return $this->modfastprevious[$selfcmid];
}
if (isset($this->sectionfastprevious[$selfsectionid])) {
return $this->sectionfastprevious[$selfsectionid];
}
return null;
}
/**
* Loads static information about a course elements previous activities.
*
* Populates two variables:
* - $this->sectionprevious[] course-module previous to a cmid
* - $this->sectionfastprevious[] course-section previous to a cmid
*
* @param stdClass $course course object
*/
private function load_course_structure(stdClass $course): void {
// If already loaded we don't need to do anything.
if (empty($this->modfastprevious)) {
$previouscache = cache::make('availability_completion', 'previous_cache');
$this->modfastprevious = $previouscache->get("mod_{$course->id}");
$this->sectionfastprevious = $previouscache->get("sec_{$course->id}");
}
if (!empty($this->modfastprevious)) {
return;
}
if (empty($this->modfastprevious)) {
$this->modfastprevious = [];
$sectionprevious = [];
$modinfo = get_fast_modinfo($course);
$lastcmid = 0;
foreach ($modinfo->cms as $othercm) {
if ($othercm->deletioninprogress) {
continue;
}
// Save first cm of every section.
if (!isset($sectionprevious[$othercm->section])) {
$sectionprevious[$othercm->section] = $lastcmid;
}
if ($lastcmid) {
$this->modfastprevious[$othercm->id] = $lastcmid;
}
// Load previous to all cms with completion.
if ($othercm->completion == COMPLETION_TRACKING_NONE) {
continue;
}
$lastcmid = $othercm->id;
}
// Fill empty sections index.
$isections = array_reverse($modinfo->get_section_info_all());
foreach ($isections as $section) {
if (isset($sectionprevious[$section->id])) {
$lastcmid = $sectionprevious[$section->id];
} else {
$sectionprevious[$section->id] = $lastcmid;
}
}
$this->sectionfastprevious = $sectionprevious;
$previouscache->set("mod_{$course->id}", $this->modfastprevious);
$previouscache->set("sec_{$course->id}", $this->sectionfastprevious);
}
}
/**
* Returns a more readable keyword corresponding to a completion state.
*
* 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(int $completionstate): string {
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);
}
}
/**
* Obtains a string describing this restriction (whether or not
* it actually applies).
*
* @param bool $full Set true if this is the 'full information' view
* @param bool $not Set true if we are inverting the condition
* @param info $info Item we're checking
* @return string Information string (for admin) about all restrictions on
* this item
*/
public function get_description($full, $not, info $info): string {
global $USER;
$str = 'requires_';
$course = $info->get_course();
list($selfcmid, $selfsectionid) = $this->get_selfids($info);
$modname = '';
// On ajax duplicate get_fast_modinfo is called before $PAGE->set_context
// so we cannot use $PAGE->user_is_editing().
$coursecontext = \context_course::instance($course->id);
$editing = !empty($USER->editing) && has_capability('moodle/course:manageactivities', $coursecontext);
if ($this->cmid == self::OPTION_PREVIOUS && $editing) {
// Previous activity name could be inconsistent when editing due to partial page loadings.
$str .= 'previous_';
} else {
// Get name for module.
$cmid = $this->get_cmid($course, $selfcmid, $selfsectionid);
$modinfo = $info->get_modinfo();
if (!array_key_exists($cmid, $modinfo->cms) || $modinfo->cms[$cmid]->deletioninprogress) {
$modname = get_string('missing', 'availability_completion');
} else {
$modname = self::description_cm_name($modinfo->cms[$cmid]->id);
}
}
// Work out which lang string to use depending on required completion status.
if ($not) {
// Convert NOT strings to use the equivalent where possible.
switch ($this->expectedcompletion) {
case COMPLETION_INCOMPLETE:
$str .= self::get_lang_string_keyword(COMPLETION_COMPLETE);
break;
case COMPLETION_COMPLETE:
$str .= self::get_lang_string_keyword(COMPLETION_INCOMPLETE);
break;
default:
// The other two cases do not have direct opposites.
$str .= 'not_' . self::get_lang_string_keyword($this->expectedcompletion);
break;
}
} else {
$str .= self::get_lang_string_keyword($this->expectedcompletion);
}
return get_string($str, 'availability_completion', $modname);
}
/**
* Obtains a representation of the options of this condition as a string,
* for debugging.
*
* @return string Text representation of parameters
*/
protected function get_debug_string(): string {
switch ($this->expectedcompletion) {
case COMPLETION_COMPLETE :
$type = 'COMPLETE';
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');
}
$cm = $this->cmid;
if ($this->cmid == self::OPTION_PREVIOUS) {
$cm = 'opprevious';
}
return 'cm' . $cm . ' ' . $type;
}
/**
* Updates this node after restore, returning true if anything changed.
*
* @see \core_availability\tree_node\update_after_restore
*
* @param string $restoreid Restore ID
* @param int $courseid ID of target course
* @param \base_logger $logger Logger for any warnings
* @param string $name Name of this item (for use in warning messages)
* @return bool True if there was any change
*/
public function update_after_restore($restoreid, $courseid, \base_logger $logger, $name): bool {
global $DB;
$res = false;
// If we depend on the previous activity, no translation is needed.
if ($this->cmid == self::OPTION_PREVIOUS) {
return $res;
}
$rec = \restore_dbops::get_backup_ids_record($restoreid, 'course_module', $this->cmid);
if (!$rec || !$rec->newitemid) {
// If we are on the same course (e.g. duplicate) then we can just
// use the existing one.
if ($DB->record_exists('course_modules',
['id' => $this->cmid, 'course' => $courseid])) {
return $res;
}
// 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): bool {
// Have we already worked out a list of required completion values
// for this course? If so just use that.
if (!array_key_exists($course->id, self::$modsusedincondition)) {
// We don't have data for this course, build it.
$modinfo = get_fast_modinfo($course);
self::$modsusedincondition[$course->id] = [];
// 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) {
$condcmid = $cond->get_cmid($course, $othercm->id, null);
if (!empty($condcmid)) {
self::$modsusedincondition[$course->id][$condcmid] = 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) {
$condcmid = $cond->get_cmid($course, null, $section->id);
if (!empty($condcmid)) {
self::$modsusedincondition[$course->id][$condcmid] = 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 = [];
}
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;
}
}
}
@@ -0,0 +1,104 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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 $cacheinitparams = [];
/**
* @var string IDs of course, cm, and section for cache (if any)
*/
protected $cachekey = '';
protected function get_javascript_strings() {
return ['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 = [];
$modinfo = get_fast_modinfo($course);
$previouscm = false;
foreach ($modinfo->cms as $id => $othercm) {
// Add each course-module if it has completion turned on and is not
// the one currently being edited.
if ($othercm->completion && (empty($cm) || $cm->id != $id) && !$othercm->deletioninprogress) {
$cms[] = (object)['id' => $id,
'name' => format_string($othercm->name, true, ['context' => $context]),
'completiongradeitemnumber' => $othercm->completiongradeitemnumber];
}
if (count($cms) && (empty($cm) || $cm->id == $id)) {
$previouscm = true;
}
}
if ($previouscm) {
$previous = (object)['id' => \availability_completion\condition::OPTION_PREVIOUS,
'name' => get_string('option_previous', 'availability_completion'),
'completiongradeitemnumber' => \availability_completion\condition::OPTION_PREVIOUS];
array_unshift($cms, $previous);
}
$this->cachekey = $cachekey;
$this->cacheinitparams = [$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;
}
}
@@ -0,0 +1,46 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Privacy Subsystem implementation for availability_completion.
*
* @package availability_completion
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace availability_completion\privacy;
defined('MOODLE_INTERNAL') || die();
/**
* Privacy Subsystem for availability_completion implementing null_provider.
*
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider implements \core_privacy\local\metadata\null_provider {
/**
* Get the language string identifier with the component's language
* file to explain why this plugin stores no data.
*
* @return string
*/
public static function get_reason(): string {
return 'privacy:metadata';
}
}
@@ -0,0 +1,35 @@
<?php
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Defined caches used internally by the plugin.
*
* @package availability_completion
* @category cache
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$definitions = [
'previous_cache' => [
'mode' => cache_store::MODE_REQUEST,
'simplekeys' => true,
'simpledata' => true,
'staticacceleration' => true
],
];
@@ -0,0 +1,51 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Language strings.
*
* @package availability_completion
* @copyright 2014 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
$string['cachedef_previous_cache'] = 'Previous activity dependency information';
$string['description'] = 'Require students to complete (or not complete) another activity.';
$string['error_selectcmid'] = 'You must select an activity for the completion condition.';
$string['error_selectcmidpassfail'] = 'You must select an activity with "Require grade" completion condition set.';
$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['option_previous'] = 'Previous activity with completion';
$string['pluginname'] = 'Restriction by activity completion';
$string['requires_incomplete'] = 'The activity <strong>{$a}</strong> is incomplete';
$string['requires_complete'] = 'The activity <strong>{$a}</strong> is marked complete';
$string['requires_complete_pass'] = 'The activity <strong>{$a}</strong> is complete and passed';
$string['requires_complete_fail'] = 'The activity <strong>{$a}</strong> is complete and failed';
$string['requires_not_complete_pass'] = 'The activity <strong>{$a}</strong> is not complete and passed';
$string['requires_not_complete_fail'] = 'The activity <strong>{$a}</strong> is not complete and failed';
$string['requires_previous_incomplete'] = 'The <strong>previous activity with completion</strong> is incomplete';
$string['requires_previous_complete'] = 'The <strong>previous activity with completion</strong> is marked complete';
$string['requires_previous_complete_pass'] = 'The <strong>previous activity with completion</strong> is complete and passed';
$string['requires_previous_complete_fail'] = 'The <strong>previous activity with completion</strong> is complete and failed';
$string['requires_previous_not_complete_pass'] = 'The <strong>previous activity with completion</strong> is not complete and passed';
$string['requires_previous_not_complete_fail'] = 'The <strong>previous activity with completion</strong> is not complete and failed';
$string['title'] = 'Activity completion';
$string['privacy:metadata'] = 'The Restriction by activity completion plugin does not store any personal data.';
@@ -0,0 +1,145 @@
@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 the following "activities" exist:
| activity | course | name | completion |
| page | C1 | Page 1 | 1 |
| page | C1 | Page 2 | |
| page | C1 | Page 3 | 1 |
| page | C1 | Page 4 | |
@javascript
Scenario: Test condition
# Basic setup.
Given I am on the "Page 2" "page activity editing" page logged in as "teacher1"
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 am on the "Course 1" "course" page logged in as "student1"
# Page 2 should not appear yet.
Then I should not see "Page 2" in the "region-main" "region"
# Mark page 1 complete
When I toggle the manual completion state of "Page 1"
Then I should see "Page 2" in the "region-main" "region"
@javascript
Scenario: Test completion and course cache rebuild
Given the following "activities" exist:
| activity | name | course | idnumber | completion | completionview | completionpostsenabled | completionposts |
| forum | forum 1 | C1 | forum1 | 2 | 1 | 1 | 2 |
And the following "mod_forum > discussions" exist:
| forum | subject | message |
| forum1 | Forum post 1 | This is the body |
And I am on the "Page 2" "page activity editing" page logged in as "teacher1"
And I expand all fieldsets
And I press "Add restriction..."
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 following fields to these values:
| Required completion status | must be marked complete |
| cm | forum 1 |
And I press "Save and return to course"
When I am on the "Course 1" "course" page logged in as "student1"
# Page 2 should not appear yet.
Then I should not see "Page 2" in the "region-main" "region"
And I click on "forum 1" "link" in the "region-main" "region"
# Page 2 should not appear yet.
And I should not see "Page 2" in the "region-main" "region"
And I am on the "forum 1" "forum activity editing" page logged in as "teacher1"
And I expand all fieldsets
And I set the following fields to these values:
| completionpostsenabled | 0 |
And I press "Save and display"
And I am on the "Course 1" "course" page logged in as "student1"
And I click on "forum 1" "link" in the "region-main" "region"
And I am on "Course 1" course homepage
And I should see "Page 2" in the "region-main" "region"
@javascript
Scenario Outline: Restrict access for activity completion should display correctly
Given the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | truefalse | First question | Answer the first question |
And the following "activities" exist:
| activity | name | course | idnumber | gradepass | completion | completionpassgrade | completionusegrade |
| quiz | Test quiz name | C1 | quiz1 | 5.00 | 2 | 1 | 1 |
And quiz "Test quiz name" contains the following questions:
| question | page |
| First question | 1 |
And I am on the "Page 2" "page activity editing" page logged in as "teacher1"
And I expand all fieldsets
And I press "Add restriction..."
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 following fields to these values:
| Required completion status | <condition> |
| cm | quiz |
And I press "Save and return to course"
And I am on the "Course 1" "course" page logged in as "student1"
And I <shouldornot> see "Page 2" in the "region-main" "region"
# Failed grade for quiz.
When user "student1" has attempted "Test quiz name" with responses:
| slot | response |
| 1 | <answer1> |
And I reload the page
And I <shouldornotanswer1> see "Page 2" in the "region-main" "region"
# Passing grade for quiz.
But user "student1" has attempted "Test quiz name" with responses:
| slot | response |
| 1 | <answer2> |
And I reload the page
And I <shouldornotanswer2> see "Page 2" in the "region-main" "region"
Examples:
| condition | answer1 | answer2 | shouldornot | shouldornotanswer1 | shouldornotanswer2 |
| must be marked complete | False | True | should not | should not | should |
| must not be marked complete | False | True | should | should | should not |
| must be complete with pass grade | False | True | should not | should not | should |
| must be complete with fail grade | False | True | should not | should | should not |
@javascript
Scenario: Edit dependent activity name should also change the access restriction message
Given I am on the "Page 2" "page activity editing" page logged in as "teacher1"
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"
And I am on the "Page 4" "page activity editing" page
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 3"
And I press "Save and return to course"
And I switch editing mode on
And I set the field "Edit title" in the "Page 1" "activity" to "Page X"
And I wait until the page is ready
Then I should see "Not available unless: The activity Page X is marked complete" in the "Page 2" "activity"
Then I should see "Not available unless: The activity Page 3 is marked complete" in the "Page 4" "activity"
@@ -0,0 +1,204 @@
@availability @availability_completion
Feature: Confirm that availability_completion works with previous activity setting
In order to control student access to activities
As a teacher
I need to set completion conditions which prevent student access
Background:
Given the following "courses" exist:
| fullname | shortname | format | enablecompletion | numsections |
| Course 1 | C1 | topics | 1 | 5 |
And the following "users" exist:
| username |
| teacher1 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
Given the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | completion | section |
| page | Page1 | Page 1 description | C1 | page1 | 1 | 1 | 1 |
| page | Page Ignored 1 | Page Ignored | C1 | pagei1 | 1 | 0 | 1 |
| page | Page2 | Page 2 description | C1 | page2 | 1 | 1 | 3 |
| page | Page3 | Page 3 description | C1 | page3 | 1 | 1 | 4 |
@javascript
Scenario: Test condition with previous activity on an activity
Given I log in as "teacher1"
And I am on "Course 1" course homepage with editing mode on
# Set Page3 restriction to Previous Activity with completion.
When I open "Page3" actions menu
And I click on "Edit settings" "link" in the "Page3" activity
And I expand all fieldsets
And I click on "Add restriction..." "button"
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
And I click on "Displayed if student doesn't meet this condition Click to hide" "link"
And I set the field "Activity or resource" to "Previous activity with completion"
And I press "Save and return to course"
Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
When I turn editing mode off
Then I should see "Not available unless: The activity Page2 is marked complete" in the "region-main" "region"
# Remove Page 2 and check Page3 depends now on Page1.
When I turn editing mode on
And I change window size to "large"
And I delete "Page2" activity
And I turn editing mode off
Then I should see "Not available unless: The activity Page1 is marked complete" in the "region-main" "region"
@javascript
Scenario: Test previous activity availability when duplicate an activity
Given I log in as "teacher1"
And I am on "Course 1" course homepage with editing mode on
# Set Page3 restriction to Previous Activity with completion.
When I open "Page3" actions menu
And I click on "Edit settings" "link" in the "Page3" activity
And I expand all fieldsets
And I click on "Add restriction..." "button"
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
And I click on "Displayed if student doesn't meet this condition Click to hide" "link"
And I set the field "Activity or resource" to "Previous activity with completion"
And I press "Save and return to course"
Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
When I turn editing mode off
Then I should see "Not available unless: The activity Page2 is marked complete" in the "region-main" "region"
# Duplicate Page3.
When I turn editing mode on
And I duplicate "Page3" activity
And I turn editing mode off
Then I should see "Not available unless: The activity Page3 is marked complete" in the "region-main" "region"
@javascript
Scenario: Test previous activity availability when modify completion tacking
Given I log in as "teacher1"
And I am on "Course 1" course homepage with editing mode on
# Set Page3 restriction to Previous Activity with completion.
When I open "Page3" actions menu
And I click on "Edit settings" "link" in the "Page3" activity
And I expand all fieldsets
And I click on "Add restriction..." "button"
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
And I click on "Displayed if student doesn't meet this condition Click to hide" "link"
And I set the field "Activity or resource" to "Previous activity with completion"
And I press "Save and return to course"
Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
When I turn editing mode off
Then I should see "Not available unless: The activity Page2 is marked complete" in the "region-main" "region"
# Test if I disable completion tracking on Page2 section 5 depends on Page2.
When I turn editing mode on
And I change window size to "large"
When I open "Page2" actions menu
And I click on "Edit settings" "link" in the "Page2" activity
And I set the following fields to these values:
| None | 1 |
And I press "Save and return to course"
When I turn editing mode off
Then I should see "Not available unless: The activity Page1 is marked complete" in the "region-main" "region"
@javascript
Scenario: Test condition with previous activity on a section
Given I log in as "teacher1"
And I am on "Course 1" course homepage with editing mode on
# Set section 4 restriction to Previous Activity with completion.
When I edit the section "4"
And I expand all fieldsets
And I click on "Add restriction..." "button"
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
And I click on "Displayed if student doesn't meet this condition Click to hide" "link"
And I set the field "Activity or resource" to "Previous activity with completion"
And I press "Save changes"
Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
When I turn editing mode off
Then I should see "Not available unless: The activity Page2 is marked complete" in the "region-main" "region"
# Remove Page 2 and check Section 4 depends now on Page1.
When I am on "Course 1" course homepage with editing mode on
And I change window size to "large"
And I delete "Page2" activity
And I turn editing mode off
Then I should see "Not available unless: The activity Page1 is marked complete" in the "region-main" "region"
@javascript
Scenario: Test condition with previous activity on the first activity of the course
Given I log in as "teacher1"
And I am on "Course 1" course homepage with editing mode on
# Try to set Page1 restriction to Previous Activity with completion.
When I open "Page1" actions menu
And I click on "Edit settings" "link" in the "Page1" activity
And I expand all fieldsets
And I click on "Add restriction..." "button"
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
And I click on "Displayed if student doesn't meet this condition Click to hide" "link"
Then the "Activity or resource" select box should not contain "Previous activity with completion"
# Set Page2 restriction to Previous Activity with completion and delete Page1.
When I am on "Course 1" course homepage
When I open "Page2" actions menu
And I click on "Edit settings" "link" in the "Page2" activity
And I expand all fieldsets
And I click on "Add restriction..." "button"
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
And I click on "Displayed if student doesn't meet this condition Click to hide" "link"
And I set the field "Activity or resource" to "Previous activity with completion"
And I press "Save and return to course"
Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
# Delete Page 1 and check than Page2 now depends on a missing activity (no previous activity found).
When I am on "Course 1" course homepage
And I delete "Page1" activity
And I turn editing mode off
Then I should see "Not available unless: The activity (Missing activity)" in the "region-main" "region"
@javascript
Scenario: Test previous activities on empty sections
Given I log in as "teacher1"
And I am on "Course 1" course homepage with editing mode on
And I change window size to "large"
# Set section 2 restriction to Previous Activity with completion.
When I edit the section "2"
And I expand all fieldsets
And I click on "Add restriction..." "button"
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
And I click on "Displayed if student doesn't meet this condition Click to hide" "link"
And I set the field "Activity or resource" to "Previous activity with completion"
And I press "Save changes"
Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
And I turn editing mode off
And I should see "Not available unless: The activity Page1 is marked complete" in the "region-main" "region"
# Set section 5 restriction to Previous Activity with completion.
And I am on "Course 1" course homepage with editing mode on
And I edit the section "5"
And I expand all fieldsets
And I click on "Add restriction..." "button"
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
And I click on "Displayed if student doesn't meet this condition Click to hide" "link"
And I set the field "Activity or resource" to "Previous activity with completion"
And I press "Save changes"
And I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
And I turn editing mode off
Then I should see "Not available unless: The activity Page3 is marked complete" in the "region-main" "region"
# Test if I disable completion tracking on Page3 section 5 depends on Page2.
And I am on "Course 1" course homepage with editing mode on
And I open "Page3" actions menu
And I click on "Edit settings" "link" in the "Page3" activity
And I set the following fields to these values:
| None | 1 |
And I press "Save and return to course"
And I turn editing mode off
And I should see "Not available unless: The activity Page2 is marked complete" in the "region-main" "region"
@@ -0,0 +1,47 @@
@availability @availability_completion
Feature: Confirm that conditions on completion no longer cause a bug
In order to use completion conditions
As a teacher
I need it to not break when I set up certain conditions on some modules
Background:
Given the following "courses" exist:
| fullname | shortname | format | enablecompletion |
| Course 1 | C1 | topics | 1 |
And the following "users" exist:
| username |
| teacher1 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
@javascript
Scenario: Multiple completion conditions on glossary
# Add a couple of Pages with manual completion.
And the following "activities" exist:
| activity | course | name | completion |
| page | C1 | Page1 | 1 |
| page | C1 | Page2 | 1 |
And I log in as "teacher1"
# Add a Glossary.
When I add a glossary activity to course "Course 1" section "1"
And I set the following fields to these values:
| Name | TestGlossary |
And I expand all fieldsets
# Add restrictions to the previous Pages being complete.
And I press "Add restriction..."
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
And I set the field "Activity or resource" to "Page1"
And I press "Add restriction..."
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
And I set the field with xpath "//div[contains(concat(' ', normalize-space(@class), ' '), ' availability-item ')][preceding-sibling::div]//select[@name='cm']" to "Page2"
And I press "Save and return to course"
And I click on "Show more" "button" in the "TestGlossary" "core_availability > Activity availability"
Then I should see "Not available unless:" in the ".activity.glossary" "css_element"
And I should see "The activity Page1 is marked complete" in the ".activity.glossary" "css_element"
And I should see "The activity Page2 is marked complete" in the ".activity.glossary" "css_element"
# Behat will automatically check there is no error on this page.
And I am on the TestGlossary "glossary activity" page
And I should see "TestGlossary"
@@ -0,0 +1,800 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace availability_completion;
defined('MOODLE_INTERNAL') || die();
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 condition_test extends \advanced_testcase {
/**
* Setup to ensure that fixtures are loaded.
*/
public static function setupBeforeClass(): void {
global $CFG;
// Load the mock info class so that it can be used.
require_once($CFG->dirroot . '/availability/tests/fixtures/mock_info.php');
require_once($CFG->dirroot . '/availability/tests/fixtures/mock_info_module.php');
require_once($CFG->dirroot . '/availability/tests/fixtures/mock_info_section.php');
}
/**
* Load required classes.
*/
public function setUp(): void {
condition::wipe_static_cache();
}
/**
* Tests constructing and using condition as part of tree.
*/
public function test_in_tree(): void {
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(['enablecompletion' => 1]);
$page = $generator->get_plugin_generator('mod_page')->create_instance(
['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]);
$selfpage = $generator->get_plugin_generator('mod_page')->create_instance(
['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]);
$modinfo = get_fast_modinfo($course);
$cm = $modinfo->get_cm($page->cmid);
$info = new \core_availability\mock_info($course, $USER->id);
$structure = (object)[
'op' => '|',
'show' => true,
'c' => [
(object)[
'type' => 'completion',
'cm' => (int)$cm->id,
'e' => COMPLETION_COMPLETE
]
]
];
$tree = new \core_availability\tree($structure);
// Initial check (user has not completed activity).
$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(): void {
// No parameters.
$structure = new \stdClass();
try {
$cond = new condition($structure);
$this->fail();
} catch (\coding_exception $e) {
$this->assertStringContainsString('Missing or invalid ->cm', $e->getMessage());
}
// Invalid $cm.
$structure->cm = 'hello';
try {
$cond = new condition($structure);
$this->fail();
} catch (\coding_exception $e) {
$this->assertStringContainsString('Missing or invalid ->cm', $e->getMessage());
}
// Missing $e.
$structure->cm = 42;
try {
$cond = new condition($structure);
$this->fail();
} catch (\coding_exception $e) {
$this->assertStringContainsString('Missing or invalid ->e', $e->getMessage());
}
// Invalid $e.
$structure->e = 99;
try {
$cond = new condition($structure);
$this->fail();
} catch (\coding_exception $e) {
$this->assertStringContainsString('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);
// Successful contruct with previous activity.
$structure->cm = condition::OPTION_PREVIOUS;
$cond = new condition($structure);
$this->assertEquals('{completion:cmopprevious INCOMPLETE}', (string)$cond);
}
/**
* Tests the save() function.
*/
public function test_save(): void {
$structure = (object)['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(): void {
global $CFG, $DB;
require_once($CFG->dirroot . '/mod/assign/locallib.php');
$this->resetAfterTest();
// Create course with completion turned on.
$CFG->enablecompletion = true;
$CFG->enableavailability = true;
$generator = $this->getDataGenerator();
$course = $generator->create_course(['enablecompletion' => 1]);
$user = $generator->create_user();
$generator->enrol_user($user->id, $course->id);
$this->setUser($user);
// Create a Page with manual completion for basic checks.
$page = $generator->get_plugin_generator('mod_page')->create_instance(
['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', [
'course' => $course->id, 'name' => 'Assign!',
'completion' => COMPLETION_TRACKING_AUTOMATIC]);
$DB->set_field('course_modules', 'completiongradeitemnumber', 0,
['id' => $assignrow->cmid]);
// As we manually set the field here, we are going to need to reset the modinfo cache.
rebuild_course_cache($course->id, true);
$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)[
'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->assertMatchesRegularExpression('~Page!.*is marked complete~', $information);
$this->assertTrue($cond->is_available(true, $info, true, $user->id));
// INCOMPLETE state (true).
$cond = new condition((object)[
'cm' => (int)$pagecm->id, 'e' => COMPLETION_INCOMPLETE
]);
$this->assertTrue($cond->is_available(false, $info, true, $user->id));
$this->assertFalse($cond->is_available(true, $info, true, $user->id));
$information = $cond->get_description(false, true, $info);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~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)[
'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->assertMatchesRegularExpression('~Page!.*is incomplete~', $information);
// INCOMPLETE state (false).
$cond = new condition((object)[
'cm' => (int)$pagecm->id, 'e' => COMPLETION_INCOMPLETE
]);
$this->assertFalse($cond->is_available(false, $info, true, $user->id));
$information = $cond->get_description(false, false, $info);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~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, ['gradepass' => 50.0]);
$gradeitem->update();
// With no grade, it should return true for INCOMPLETE and false for
// the other three.
$cond = new condition((object)[
'cm' => (int)$assigncm->id, 'e' => COMPLETION_INCOMPLETE
]);
$this->assertTrue($cond->is_available(false, $info, true, $user->id));
$this->assertFalse($cond->is_available(true, $info, true, $user->id));
$cond = new condition((object)[
'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)[
'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->assertMatchesRegularExpression('~Assign!.*is complete and passed~', $information);
$this->assertTrue($cond->is_available(true, $info, true, $user->id));
$cond = new condition((object)[
'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_FAIL
]);
$this->assertFalse($cond->is_available(false, $info, true, $user->id));
$information = $cond->get_description(false, false, $info);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~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)[
'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)[
'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));
$cond = new condition((object)[
'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_PASS
]);
$this->assertFalse($cond->is_available(false, $info, true, $user->id));
$information = $cond->get_description(false, false, $info);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~Assign!.*is complete and passed~', $information);
$this->assertTrue($cond->is_available(true, $info, true, $user->id));
$cond = new condition((object)[
'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_FAIL
]);
$this->assertTrue($cond->is_available(false, $info, true, $user->id));
$this->assertFalse($cond->is_available(true, $info, true, $user->id));
$information = $cond->get_description(false, true, $info);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~Assign!.*is not complete and failed~', $information);
// Now change it to pass.
self::set_grade($assignrow, $user->id, 60);
$cond = new condition((object)[
'cm' => (int)$assigncm->id, 'e' => COMPLETION_INCOMPLETE
]);
$this->assertFalse($cond->is_available(false, $info, true, $user->id));
$this->assertTrue($cond->is_available(true, $info, true, $user->id));
$cond = new condition((object)[
'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)[
'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->assertMatchesRegularExpression('~Assign!.*is not complete and passed~', $information);
$cond = new condition((object)[
'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_FAIL
]);
$this->assertFalse($cond->is_available(false, $info, true, $user->id));
$information = $cond->get_description(false, false, $info);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~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)[
'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->assertMatchesRegularExpression('~(Missing activity).*is marked complete~', $information);
$this->assertFalse($cond->is_available(true, $info, true, $user->id));
$cond = new condition((object)[
'cm' => ($assigncm->id + 100), 'e' => COMPLETION_INCOMPLETE
]);
$this->assertFalse($cond->is_available(false, $info, true, $user->id));
}
/**
* Tests the is_available and get_description functions for previous activity option.
*
* @dataProvider previous_activity_data
* @param int $grade the current assign grade (0 for none)
* @param int $condition true for complete, false for incomplete
* @param string $mark activity to mark as complete
* @param string $activity activity name to test
* @param bool $result if it must be available or not
* @param bool $resultnot if it must be available when the condition is inverted
* @param string $description the availabiklity text to check
*/
public function test_previous_activity(int $grade, int $condition, string $mark, string $activity,
bool $result, bool $resultnot, string $description): void {
global $CFG, $DB;
require_once($CFG->dirroot . '/mod/assign/locallib.php');
$this->resetAfterTest();
// Create course with completion turned on.
$CFG->enablecompletion = true;
$CFG->enableavailability = true;
$generator = $this->getDataGenerator();
$course = $generator->create_course(['enablecompletion' => 1]);
$user = $generator->create_user();
$generator->enrol_user($user->id, $course->id);
$this->setUser($user);
// Page 1 (manual completion).
$page1 = $generator->get_plugin_generator('mod_page')->create_instance(
['course' => $course->id, 'name' => 'Page1!',
'completion' => COMPLETION_TRACKING_MANUAL]);
// Page 2 (manual completion).
$page2 = $generator->get_plugin_generator('mod_page')->create_instance(
['course' => $course->id, 'name' => 'Page2!',
'completion' => COMPLETION_TRACKING_MANUAL]);
// Page ignored (no completion).
$pagenocompletion = $generator->get_plugin_generator('mod_page')->create_instance(
['course' => $course->id, 'name' => 'Page ignored!']);
// Create an assignment - we need to have something that can be graded
// so as to test the PASS/FAIL states. Set it up to be completed based
// on its grade item.
$assignrow = $this->getDataGenerator()->create_module('assign', [
'course' => $course->id, 'name' => 'Assign!',
'completion' => COMPLETION_TRACKING_AUTOMATIC
]);
$DB->set_field('course_modules', 'completiongradeitemnumber', 0,
['id' => $assignrow->cmid]);
$assign = new \assign(\context_module::instance($assignrow->cmid), false, false);
// Page 3 (manual completion).
$page3 = $generator->get_plugin_generator('mod_page')->create_instance(
['course' => $course->id, 'name' => 'Page3!',
'completion' => COMPLETION_TRACKING_MANUAL]);
// Get basic details.
$activities = [];
$modinfo = get_fast_modinfo($course);
$activities['page1'] = $modinfo->get_cm($page1->cmid);
$activities['page2'] = $modinfo->get_cm($page2->cmid);
$activities['assign'] = $assign->get_course_module();
$activities['page3'] = $modinfo->get_cm($page3->cmid);
$prevvalue = condition::OPTION_PREVIOUS;
// Setup gradings and completion.
if ($grade) {
$gradeitem = $assign->get_grade_item();
\grade_object::set_properties($gradeitem, ['gradepass' => 50.0]);
$gradeitem->update();
self::set_grade($assignrow, $user->id, $grade);
}
if ($mark) {
$completion = new \completion_info($course);
$completion->update_state($activities[$mark], COMPLETION_COMPLETE);
}
// Set opprevious WITH non existent previous activity.
$info = new \core_availability\mock_info_module($user->id, $activities[$activity]);
$cond = new condition((object)[
'cm' => (int)$prevvalue, 'e' => $condition
]);
// Do the checks.
$this->assertEquals($result, $cond->is_available(false, $info, true, $user->id));
$this->assertEquals($resultnot, $cond->is_available(true, $info, true, $user->id));
$information = $cond->get_description(false, false, $info);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression($description, $information);
}
public function previous_activity_data(): array {
// Assign grade, condition, activity to complete, activity to test, result, resultnot, description.
return [
'Missing previous activity complete' => [
0, COMPLETION_COMPLETE, '', 'page1', false, false, '~Missing activity.*is marked complete~'
],
'Missing previous activity incomplete' => [
0, COMPLETION_INCOMPLETE, '', 'page1', false, false, '~Missing activity.*is incomplete~'
],
'Previous complete condition with previous activity incompleted' => [
0, COMPLETION_COMPLETE, '', 'page2', false, true, '~Page1!.*is marked complete~'
],
'Previous incomplete condition with previous activity incompleted' => [
0, COMPLETION_INCOMPLETE, '', 'page2', true, false, '~Page1!.*is incomplete~'
],
'Previous complete condition with previous activity completed' => [
0, COMPLETION_COMPLETE, 'page1', 'page2', true, false, '~Page1!.*is marked complete~'
],
'Previous incomplete condition with previous activity completed' => [
0, COMPLETION_INCOMPLETE, 'page1', 'page2', false, true, '~Page1!.*is incomplete~'
],
// Depenging on page pass fail (pages are not gradable).
'Previous complete pass condition with previous no gradable activity incompleted' => [
0, COMPLETION_COMPLETE_PASS, '', 'page2', false, true, '~Page1!.*is complete and passed~'
],
'Previous complete fail condition with previous no gradable activity incompleted' => [
0, COMPLETION_COMPLETE_FAIL, '', 'page2', false, true, '~Page1!.*is complete and failed~'
],
'Previous complete pass condition with previous no gradable activity completed' => [
0, COMPLETION_COMPLETE_PASS, 'page1', 'page2', false, true, '~Page1!.*is complete and passed~'
],
'Previous complete fail condition with previous no gradable activity completed' => [
0, COMPLETION_COMPLETE_FAIL, 'page1', 'page2', false, true, '~Page1!.*is complete and failed~'
],
// There's an page without completion between page2 ans assign.
'Previous complete condition with sibling activity incompleted' => [
0, COMPLETION_COMPLETE, '', 'assign', false, true, '~Page2!.*is marked complete~'
],
'Previous incomplete condition with sibling activity incompleted' => [
0, COMPLETION_INCOMPLETE, '', 'assign', true, false, '~Page2!.*is incomplete~'
],
'Previous complete condition with sibling activity completed' => [
0, COMPLETION_COMPLETE, 'page2', 'assign', true, false, '~Page2!.*is marked complete~'
],
'Previous incomplete condition with sibling activity completed' => [
0, COMPLETION_INCOMPLETE, 'page2', 'assign', false, true, '~Page2!.*is incomplete~'
],
// Depending on assign without grade.
'Previous complete condition with previous without grade' => [
0, COMPLETION_COMPLETE, '', 'page3', false, true, '~Assign!.*is marked complete~'
],
'Previous incomplete condition with previous without grade' => [
0, COMPLETION_INCOMPLETE, '', 'page3', true, false, '~Assign!.*is incomplete~'
],
'Previous complete pass condition with previous without grade' => [
0, COMPLETION_COMPLETE_PASS, '', 'page3', false, true, '~Assign!.*is complete and passed~'
],
'Previous complete fail condition with previous without grade' => [
0, COMPLETION_COMPLETE_FAIL, '', 'page3', false, true, '~Assign!.*is complete and failed~'
],
// Depending on assign with grade.
'Previous complete condition with previous fail grade' => [
40, COMPLETION_COMPLETE, '', 'page3', false, true, '~Assign!.*is marked complete~',
],
'Previous incomplete condition with previous fail grade' => [
40, COMPLETION_INCOMPLETE, '', 'page3', true, false, '~Assign!.*is incomplete~',
],
'Previous complete pass condition with previous fail grade' => [
40, COMPLETION_COMPLETE_PASS, '', 'page3', false, true, '~Assign!.*is complete and passed~'
],
'Previous complete fail condition with previous fail grade' => [
40, COMPLETION_COMPLETE_FAIL, '', 'page3', true, false, '~Assign!.*is complete and failed~'
],
'Previous complete condition with previous pass grade' => [
60, COMPLETION_COMPLETE, '', 'page3', true, false, '~Assign!.*is marked complete~'
],
'Previous incomplete condition with previous pass grade' => [
60, COMPLETION_INCOMPLETE, '', 'page3', false, true, '~Assign!.*is incomplete~'
],
'Previous complete pass condition with previous pass grade' => [
60, COMPLETION_COMPLETE_PASS, '', 'page3', true, false, '~Assign!.*is complete and passed~'
],
'Previous complete fail condition with previous pass grade' => [
60, COMPLETION_COMPLETE_FAIL, '', 'page3', false, true, '~Assign!.*is complete and failed~'
],
];
}
/**
* Tests the is_available and get_description functions for
* previous activity option in course sections.
*
* @dataProvider section_previous_activity_data
* @param int $condition condition value
* @param bool $mark if Page 1 must be mark as completed
* @param string $section section to add the availability
* @param bool $result expected result
* @param bool $resultnot expected negated result
* @param string $description description to match
*/
public function test_section_previous_activity(int $condition, bool $mark, string $section,
bool $result, bool $resultnot, string $description): void {
global $CFG, $DB;
require_once($CFG->dirroot . '/mod/assign/locallib.php');
$this->resetAfterTest();
// Create course with completion turned on.
$CFG->enablecompletion = true;
$CFG->enableavailability = true;
$generator = $this->getDataGenerator();
$course = $generator->create_course(
['numsections' => 4, 'enablecompletion' => 1],
['createsections' => true]);
$user = $generator->create_user();
$generator->enrol_user($user->id, $course->id);
$this->setUser($user);
// Section 1 - page1 (manual completion).
$page1 = $generator->get_plugin_generator('mod_page')->create_instance(
['course' => $course->id, 'name' => 'Page1!', 'section' => 1,
'completion' => COMPLETION_TRACKING_MANUAL]);
// Section 1 - page ignored 1 (no completion).
$pagenocompletion1 = $generator->get_plugin_generator('mod_page')->create_instance(
['course' => $course, 'name' => 'Page ignored!', 'section' => 1]);
// Section 2 - page ignored 2 (no completion).
$pagenocompletion2 = $generator->get_plugin_generator('mod_page')->create_instance(
['course' => $course, 'name' => 'Page ignored!', 'section' => 2]);
// Section 3 - page2 (manual completion).
$page2 = $generator->get_plugin_generator('mod_page')->create_instance(
['course' => $course->id, 'name' => 'Page2!', 'section' => 3,
'completion' => COMPLETION_TRACKING_MANUAL]);
// Section 4 is empty.
// Get basic details.
get_fast_modinfo(0, 0, true);
$modinfo = get_fast_modinfo($course);
$sections['section1'] = $modinfo->get_section_info(1);
$sections['section2'] = $modinfo->get_section_info(2);
$sections['section3'] = $modinfo->get_section_info(3);
$sections['section4'] = $modinfo->get_section_info(4);
$page1cm = $modinfo->get_cm($page1->cmid);
$prevvalue = condition::OPTION_PREVIOUS;
if ($mark) {
// Mark page1 complete.
$completion = new \completion_info($course);
$completion->update_state($page1cm, COMPLETION_COMPLETE);
}
$info = new \core_availability\mock_info_section($user->id, $sections[$section]);
$cond = new condition((object)[
'cm' => (int)$prevvalue, 'e' => $condition
]);
$this->assertEquals($result, $cond->is_available(false, $info, true, $user->id));
$this->assertEquals($resultnot, $cond->is_available(true, $info, true, $user->id));
$information = $cond->get_description(false, false, $info);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression($description, $information);
}
public function section_previous_activity_data(): array {
return [
// Condition, Activity completion, section to test, result, resultnot, description.
'Completion complete Section with no previous activity' => [
COMPLETION_COMPLETE, false, 'section1', false, false, '~Missing activity.*is marked complete~'
],
'Completion incomplete Section with no previous activity' => [
COMPLETION_INCOMPLETE, false, 'section1', false, false, '~Missing activity.*is incomplete~'
],
// Section 2 depending on section 1 -> Page 1 (no grading).
'Completion complete Section with previous activity incompleted' => [
COMPLETION_COMPLETE, false, 'section2', false, true, '~Page1!.*is marked complete~'
],
'Completion incomplete Section with previous activity incompleted' => [
COMPLETION_INCOMPLETE, false, 'section2', true, false, '~Page1!.*is incomplete~'
],
'Completion complete Section with previous activity completed' => [
COMPLETION_COMPLETE, true, 'section2', true, false, '~Page1!.*is marked complete~'
],
'Completion incomplete Section with previous activity completed' => [
COMPLETION_INCOMPLETE, true, 'section2', false, true, '~Page1!.*is incomplete~'
],
// Section 3 depending on section 1 -> Page 1 (no grading).
'Completion complete Section ignoring empty sections and activity incompleted' => [
COMPLETION_COMPLETE, false, 'section3', false, true, '~Page1!.*is marked complete~'
],
'Completion incomplete Section ignoring empty sections and activity incompleted' => [
COMPLETION_INCOMPLETE, false, 'section3', true, false, '~Page1!.*is incomplete~'
],
'Completion complete Section ignoring empty sections and activity completed' => [
COMPLETION_COMPLETE, true, 'section3', true, false, '~Page1!.*is marked complete~'
],
'Completion incomplete Section ignoring empty sections and activity completed' => [
COMPLETION_INCOMPLETE, true, 'section3', false, true, '~Page1!.*is incomplete~'
],
// Section 4 depending on section 3 -> Page 2 (no grading).
'Completion complete Last section with previous activity incompleted' => [
COMPLETION_COMPLETE, false, 'section4', false, true, '~Page2!.*is marked complete~'
],
'Completion incomplete Last section with previous activity incompleted' => [
COMPLETION_INCOMPLETE, false, 'section4', true, false, '~Page2!.*is incomplete~'
],
'Completion complete Last section with previous activity completed' => [
COMPLETION_COMPLETE, true, 'section4', false, true, '~Page2!.*is marked complete~'
],
'Completion incomplete Last section with previous activity completed' => [
COMPLETION_INCOMPLETE, true, 'section4', true, false, '~Page2!.*is incomplete~'
],
];
}
/**
* Tests completion_value_used static function.
*/
public function test_completion_value_used(): void {
global $CFG, $DB;
$this->resetAfterTest();
$prevvalue = condition::OPTION_PREVIOUS;
// Create course with completion turned on and some sections.
$CFG->enablecompletion = true;
$CFG->enableavailability = true;
$generator = $this->getDataGenerator();
$course = $generator->create_course(
['numsections' => 1, 'enablecompletion' => 1],
['createsections' => true]);
// Create six pages with manual completion.
$page1 = $generator->get_plugin_generator('mod_page')->create_instance(
['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]);
$page2 = $generator->get_plugin_generator('mod_page')->create_instance(
['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]);
$page3 = $generator->get_plugin_generator('mod_page')->create_instance(
['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]);
$page4 = $generator->get_plugin_generator('mod_page')->create_instance(
['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]);
$page5 = $generator->get_plugin_generator('mod_page')->create_instance(
['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]);
$page6 = $generator->get_plugin_generator('mod_page')->create_instance(
['course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL]);
// Set up page3 to depend on page1, and section1 to depend on page2.
$DB->set_field('course_modules', 'availability',
'{"op":"|","show":true,"c":[' .
'{"type":"completion","e":1,"cm":' . $page1->cmid . '}]}',
['id' => $page3->cmid]);
$DB->set_field('course_sections', 'availability',
'{"op":"|","show":true,"c":[' .
'{"type":"completion","e":1,"cm":' . $page2->cmid . '}]}',
['course' => $course->id, 'section' => 1]);
// Set up page5 and page6 to depend on previous activity.
$DB->set_field('course_modules', 'availability',
'{"op":"|","show":true,"c":[' .
'{"type":"completion","e":1,"cm":' . $prevvalue . '}]}',
['id' => $page5->cmid]);
$DB->set_field('course_modules', 'availability',
'{"op":"|","show":true,"c":[' .
'{"type":"completion","e":1,"cm":' . $prevvalue . '}]}',
['id' => $page6->cmid]);
// Check 1: nothing depends on page3 and page6 but something does on the others.
$this->assertTrue(condition::completion_value_used(
$course, $page1->cmid));
$this->assertTrue(condition::completion_value_used(
$course, $page2->cmid));
$this->assertFalse(condition::completion_value_used(
$course, $page3->cmid));
$this->assertTrue(condition::completion_value_used(
$course, $page4->cmid));
$this->assertTrue(condition::completion_value_used(
$course, $page5->cmid));
$this->assertFalse(condition::completion_value_used(
$course, $page6->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 = [];
$grades[$userid] = (object)[
'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(): void {
$cond = new condition((object)[
'cm' => 42, 'e' => COMPLETION_COMPLETE, 'selfid' => 43
]);
$this->assertFalse($cond->update_dependency_id('frogs', 42, 540));
$this->assertFalse($cond->update_dependency_id('course_modules', 12, 34));
$this->assertTrue($cond->update_dependency_id('course_modules', 42, 456));
$after = $cond->save();
$this->assertEquals(456, $after->cm);
// Test selfid updating.
$cond = new condition((object)[
'cm' => 42, 'e' => COMPLETION_COMPLETE
]);
$this->assertFalse($cond->update_dependency_id('frogs', 43, 540));
$this->assertFalse($cond->update_dependency_id('course_modules', 12, 34));
$after = $cond->save();
$this->assertEquals(42, $after->cm);
// Test on previous activity.
$cond = new condition((object)[
'cm' => condition::OPTION_PREVIOUS,
'e' => COMPLETION_COMPLETE
]);
$this->assertFalse($cond->update_dependency_id('frogs', 43, 80));
$this->assertFalse($cond->update_dependency_id('course_modules', 12, 34));
$after = $cond->save();
$this->assertEquals(condition::OPTION_PREVIOUS, $after->cm);
}
}
@@ -0,0 +1,29 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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 = 2024042200;
$plugin->requires = 2024041600;
$plugin->component = 'availability_completion';
@@ -0,0 +1,94 @@
YUI.add('moodle-availability_completion-form', function (Y, NAME) {
/**
* 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 html = '<span class="col-form-label pr-3"> ' + M.util.get_string('title', 'availability_completion') + '</span>' +
' <span class="availability-group mb-3"><label>' +
'<span class="accesshide">' + M.util.get_string('label_cm', 'availability_completion') + ' </span>' +
'<select class="custom-select" name="cm" title="' + M.util.get_string('label_cm', 'availability_completion') + '">' +
'<option value="0">' + M.util.get_string('choosedots', 'moodle') + '</option>';
for (var i = 0; i < this.cms.length; i++) {
var cm = this.cms[i];
// String has already been escaped using format_string.
html += '<option value="' + cm.id + '">' + cm.name + '</option>';
}
html += '</select></label> <label><span class="accesshide">' +
M.util.get_string('label_completion', 'availability_completion') +
' </span><select class="custom-select" ' +
'name="e" title="' + M.util.get_string('label_completion', 'availability_completion') + '">' +
'<option value="1">' + M.util.get_string('option_complete', 'availability_completion') + '</option>' +
'<option value="0">' + M.util.get_string('option_incomplete', 'availability_completion') + '</option>' +
'<option value="2">' + M.util.get_string('option_pass', 'availability_completion') + '</option>' +
'<option value="3">' + M.util.get_string('option_fail', 'availability_completion') + '</option>' +
'</select></label></span>';
var node = Y.Node.create('<span class="d-flex flex-wrap align-items-center">' + html + '</span>');
// 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('.availability-field');
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');
}
var e = parseInt(node.one('select[name=e]').get('value'), 10);
if (((e === 2) || (e === 3))) {
this.cms.forEach(function(cm) {
if (cm.id === cmid) {
if (cm.completiongradeitemnumber === null) {
errors.push('availability_completion:error_selectcmidpassfail');
}
}
});
}
};
}, '@VERSION@', {"requires": ["base", "node", "event", "moodle-core_availability-form"]});
@@ -0,0 +1 @@
YUI.add("moodle-availability_completion-form",function(o,e){M.availability_completion=M.availability_completion||{},M.availability_completion.form=o.Object(M.core_availability.plugin),M.availability_completion.form.initInner=function(e){this.cms=e},M.availability_completion.form.getNode=function(e){for(var i,l,t='<span class="col-form-label pr-3"> '+M.util.get_string("title","availability_completion")+'</span> <span class="availability-group mb-3"><label><span class="accesshide">'+M.util.get_string("label_cm","availability_completion")+' </span><select class="custom-select" name="cm" title="'+M.util.get_string("label_cm","availability_completion")+'"><option value="0">'+M.util.get_string("choosedots","moodle")+"</option>",a=0;a<this.cms.length;a++)t+='<option value="'+(i=this.cms[a]).id+'">'+i.name+"</option>";return t+='</select></label> <label><span class="accesshide">'+M.util.get_string("label_completion","availability_completion")+' </span><select class="custom-select" name="e" title="'+M.util.get_string("label_completion","availability_completion")+'"><option value="1">'+M.util.get_string("option_complete","availability_completion")+'</option><option value="0">'+M.util.get_string("option_incomplete","availability_completion")+'</option><option value="2">'+M.util.get_string("option_pass","availability_completion")+'</option><option value="3">'+M.util.get_string("option_fail","availability_completion")+"</option></select></label></span>",l=o.Node.create('<span class="d-flex flex-wrap align-items-center">'+t+"</span>"),e.cm!==undefined&&l.one("select[name=cm] > option[value="+e.cm+"]")&&l.one("select[name=cm]").set("value",""+e.cm),e.e!==undefined&&l.one("select[name=e]").set("value",""+e.e),M.availability_completion.form.addedEvents||(M.availability_completion.form.addedEvents=!0,o.one(".availability-field").delegate("change",function(){M.core_availability.form.update()},".availability_completion select")),l},M.availability_completion.form.fillValue=function(e,i){e.cm=parseInt(i.one("select[name=cm]").get("value"),10),e.e=parseInt(i.one("select[name=e]").get("value"),10)},M.availability_completion.form.fillErrors=function(i,e){var l=parseInt(e.one("select[name=cm]").get("value"),10);0===l&&i.push("availability_completion:error_selectcmid"),2!==(e=parseInt(e.one("select[name=e]").get("value"),10))&&3!==e||this.cms.forEach(function(e){e.id===l&&null===e.completiongradeitemnumber&&i.push("availability_completion:error_selectcmidpassfail")})}},"@VERSION@",{requires:["base","node","event","moodle-core_availability-form"]});
@@ -0,0 +1,94 @@
YUI.add('moodle-availability_completion-form', function (Y, NAME) {
/**
* 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 html = '<span class="col-form-label pr-3"> ' + M.util.get_string('title', 'availability_completion') + '</span>' +
' <span class="availability-group mb-3"><label>' +
'<span class="accesshide">' + M.util.get_string('label_cm', 'availability_completion') + ' </span>' +
'<select class="custom-select" name="cm" title="' + M.util.get_string('label_cm', 'availability_completion') + '">' +
'<option value="0">' + M.util.get_string('choosedots', 'moodle') + '</option>';
for (var i = 0; i < this.cms.length; i++) {
var cm = this.cms[i];
// String has already been escaped using format_string.
html += '<option value="' + cm.id + '">' + cm.name + '</option>';
}
html += '</select></label> <label><span class="accesshide">' +
M.util.get_string('label_completion', 'availability_completion') +
' </span><select class="custom-select" ' +
'name="e" title="' + M.util.get_string('label_completion', 'availability_completion') + '">' +
'<option value="1">' + M.util.get_string('option_complete', 'availability_completion') + '</option>' +
'<option value="0">' + M.util.get_string('option_incomplete', 'availability_completion') + '</option>' +
'<option value="2">' + M.util.get_string('option_pass', 'availability_completion') + '</option>' +
'<option value="3">' + M.util.get_string('option_fail', 'availability_completion') + '</option>' +
'</select></label></span>';
var node = Y.Node.create('<span class="d-flex flex-wrap align-items-center">' + html + '</span>');
// 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('.availability-field');
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');
}
var e = parseInt(node.one('select[name=e]').get('value'), 10);
if (((e === 2) || (e === 3))) {
this.cms.forEach(function(cm) {
if (cm.id === cmid) {
if (cm.completiongradeitemnumber === null) {
errors.push('availability_completion:error_selectcmidpassfail');
}
}
});
}
};
}, '@VERSION@', {"requires": ["base", "node", "event", "moodle-core_availability-form"]});
@@ -0,0 +1,10 @@
{
"name": "moodle-availability_completion-form",
"builds": {
"moodle-availability_completion-form": {
"jsfiles": [
"form.js"
]
}
}
}
@@ -0,0 +1,89 @@
/**
* 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 html = '<span class="col-form-label pr-3"> ' + M.util.get_string('title', 'availability_completion') + '</span>' +
' <span class="availability-group mb-3"><label>' +
'<span class="accesshide">' + M.util.get_string('label_cm', 'availability_completion') + ' </span>' +
'<select class="custom-select" name="cm" title="' + M.util.get_string('label_cm', 'availability_completion') + '">' +
'<option value="0">' + M.util.get_string('choosedots', 'moodle') + '</option>';
for (var i = 0; i < this.cms.length; i++) {
var cm = this.cms[i];
// String has already been escaped using format_string.
html += '<option value="' + cm.id + '">' + cm.name + '</option>';
}
html += '</select></label> <label><span class="accesshide">' +
M.util.get_string('label_completion', 'availability_completion') +
' </span><select class="custom-select" ' +
'name="e" title="' + M.util.get_string('label_completion', 'availability_completion') + '">' +
'<option value="1">' + M.util.get_string('option_complete', 'availability_completion') + '</option>' +
'<option value="0">' + M.util.get_string('option_incomplete', 'availability_completion') + '</option>' +
'<option value="2">' + M.util.get_string('option_pass', 'availability_completion') + '</option>' +
'<option value="3">' + M.util.get_string('option_fail', 'availability_completion') + '</option>' +
'</select></label></span>';
var node = Y.Node.create('<span class="d-flex flex-wrap align-items-center">' + html + '</span>');
// 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('.availability-field');
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');
}
var e = parseInt(node.one('select[name=e]').get('value'), 10);
if (((e === 2) || (e === 3))) {
this.cms.forEach(function(cm) {
if (cm.id === cmid) {
if (cm.completiongradeitemnumber === null) {
errors.push('availability_completion:error_selectcmidpassfail');
}
}
});
}
};
@@ -0,0 +1,10 @@
{
"moodle-availability_completion-form": {
"requires": [
"base",
"node",
"event",
"moodle-core_availability-form"
]
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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');
@@ -0,0 +1,307 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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);
}
/**
* Returns a JSON object which corresponds to a condition of this type.
*
* Intended for unit testing, as normally the JSON values are constructed
* by JavaScript code.
*
* @param string $direction DIRECTION_xx constant
* @param int $time Time in epoch seconds
* @return stdClass Object representing condition
*/
public static function get_json($direction, $time) {
return (object)array('type' => 'date', 'd' => $direction, 't' => (int)$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 otherwise
*/
protected static function is_midnight($time) {
return usergetmidnight($time) == $time;
}
public function update_after_restore(
$restoreid, $courseid, \base_logger $logger, $name) {
// Update the date, if restoring with changed date.
$dateoffset = \core_availability\info::get_restore_date_offset($restoreid);
if ($dateoffset) {
$this->time += $dateoffset;
return true;
}
return false;
}
/**
* Changes all date restrictions on a course by the specified shift amount.
* Used by the course reset feature.
*
* @param int $courseid Course id
* @param int $timeshift Offset in seconds
*/
public static function update_all_dates($courseid, $timeshift) {
global $DB;
$modinfo = get_fast_modinfo($courseid);
$anychanged = false;
// Adjust dates from all course modules.
foreach ($modinfo->cms as $cm) {
if (!$cm->availability) {
continue;
}
$info = new \core_availability\info_module($cm);
$tree = $info->get_availability_tree();
$dates = $tree->get_all_children('availability_date\condition');
$changed = false;
foreach ($dates as $date) {
$date->time += $timeshift;
$changed = true;
}
// Save the updated course module.
if ($changed) {
$DB->set_field('course_modules', 'availability', json_encode($tree->save()),
array('id' => $cm->id));
$anychanged = true;
}
}
// Adjust dates from all course sections.
foreach ($modinfo->get_section_info_all() as $section) {
if (!$section->availability) {
continue;
}
$info = new \core_availability\info_section($section);
$tree = $info->get_availability_tree();
$dates = $tree->get_all_children('availability_date\condition');
$changed = false;
foreach ($dates as $date) {
$date->time += $timeshift;
$changed = true;
}
// Save the updated course module.
if ($changed) {
$updatesection = new \stdClass();
$updatesection->id = $section->id;
$updatesection->availability = json_encode($tree->save());
$updatesection->timemodified = time();
$DB->update_record('course_sections', $updatesection);
// Invalidate the section cache by given section id.
\course_modinfo::purge_course_section_cache_by_id($courseid, $section->id);
$anychanged = true;
}
}
if ($anychanged) {
// Partial rebuild the sections which have been invalidated.
rebuild_course_cache($courseid, true, true);
}
}
}
@@ -0,0 +1,174 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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', 'error_dateconflict');
}
/**
* 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 = '<span class="availability-group">';
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 .= '</span> <span class="availability-group">';
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 . ']', 'class' => 'custom-select'));
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) . '</span>';
// 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);
}
}
@@ -0,0 +1,46 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Privacy Subsystem implementation for availability_date.
*
* @package availability_date
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace availability_date\privacy;
defined('MOODLE_INTERNAL') || die();
/**
* Privacy Subsystem for availability_date implementing null_provider.
*
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider implements \core_privacy\local\metadata\null_provider {
/**
* Get the language string identifier with the component's language
* file to explain why this plugin stores no data.
*
* @return string
*/
public static function get_reason(): string {
return 'privacy:metadata';
}
}
@@ -0,0 +1,42 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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['error_dateconflict'] = 'Conflicts with other date restrictions';
$string['full_from'] = 'It is after <strong>{$a}</strong>';
$string['full_from_date'] = 'It is on or after <strong>{$a}</strong>';
$string['full_until'] = 'It is before <strong>{$a}</strong>';
$string['full_until_date'] = 'It is before end of <strong>{$a}</strong>';
$string['pluginname'] = 'Restriction by date';
$string['short_from'] = 'Available from <strong>{$a}</strong>';
$string['short_from_date'] = 'Available from <strong>{$a}</strong>';
$string['short_until'] = 'Available until <strong>{$a}</strong>';
$string['short_until_date'] = 'Available until end of <strong>{$a}</strong>';
$string['title'] = 'Date';
$string['privacy:metadata'] = 'The Restriction by date plugin does not store any personal data.';
@@ -0,0 +1,50 @@
@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 the following "activities" exist:
| activity | course | name |
| page | C1 | Page 1 |
| page | C1 | Page 2 |
@javascript
Scenario: Test condition
# Add a Page with a date condition that does match (from the past).
Given I am on the "Page 1" "page activity editing" page logged in as "teacher1"
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 am on the "Page 2" "page activity editing" page
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 am on the "Course 1" "course" page logged in as "student1"
# 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"
@@ -0,0 +1,105 @@
@availability @availability_date @javascript
Feature: As a teacher I can set availability dates restriction to an activity and see a warning when conflicting dates are set
Background:
Given the following "courses" exist:
| fullname | shortname | format | enablecompletion |
| Course 1 | C1 | topics | 1 |
And the following "users" exist:
| username |
| teacher1 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "activities" exist:
| activity | name | intro | introformat | course | content | contentformat | idnumber |
| page | PageName1 | PageDesc1 | 1 | C1 | Page 1 | 1 | 1 |
Scenario: When I set dates to potential conflicting dates in the same subset, I should see a warning.
Given I am on the PageName1 "page activity editing" page logged in as teacher1
And I expand all fieldsets
And I click on "Add restriction..." "button" in the "root" "core_availability > Availability Button Area"
And I click on "Date" "button" in the "Add restriction..." "dialogue"
And I set the field "year" in the "1" "availability_date > Date Restriction" to "2023"
And I set the field "Month" in the "1" "availability_date > Date Restriction" to "April"
And I set the field "day" in the "1" "availability_date > Date Restriction" to "4"
And I set the field "Direction" in the "1" "availability_date > Date Restriction" to "from"
And I click on "Add restriction..." "button" in the "root" "core_availability > Availability Button Area"
And I click on "Date" "button" in the "Add restriction..." "dialogue"
And I set the field "year" in the "2" "availability_date > Date Restriction" to "2023"
And I set the field "Month" in the "2" "availability_date > Date Restriction" to "April"
And I set the field "day" in the "2" "availability_date > Date Restriction" to "6"
And I set the field "Direction" in the "2" "availability_date > Date Restriction" to "until"
And I click on "Add restriction..." "button" in the "root" "core_availability > Availability Button Area"
And I click on "Date" "button" in the "Add restriction..." "dialogue"
And I set the field "year" in the "3" "availability_date > Date Restriction" to "2023"
And I set the field "Month" in the "3" "availability_date > Date Restriction" to "April"
And I set the field "day" in the "3" "availability_date > Date Restriction" to "6"
When I set the field "Direction" in the "3" "availability_date > Date Restriction" to "from"
Then I should see "Conflicts with other date restrictions"
Scenario: If there are conflicting dates in the same subset, I should not see a warning if condition are separated by "any".
Given I am on the PageName1 "page activity editing" page logged in as teacher1
And I expand all fieldsets
And I click on "Add restriction..." "button" in the "root" "core_availability > Availability Button Area"
And I click on "Restriction set" "button" in the "Add restriction..." "dialogue"
And I click on "Add restriction..." "button" in the "1" "core_availability > Availability Button Area"
And I click on "Date" "button" in the "Add restriction..." "dialogue"
And I set the field "year" in the "1.1" "availability_date > Date Restriction" to "2023"
And I set the field "Month" in the "1.1" "availability_date > Date Restriction" to "April"
And I set the field "day" in the "1.1" "availability_date > Date Restriction" to "4"
And I set the field "Direction" in the "1.1" "availability_date > Date Restriction" to "from"
And I click on "Add restriction..." "button" in the "1" "core_availability > Availability Button Area"
And I click on "Date" "button" in the "Add restriction..." "dialogue"
And I set the field "year" in the "1.2" "availability_date > Date Restriction" to "2023"
And I set the field "Month" in the "1.2" "availability_date > Date Restriction" to "April"
And I set the field "day" in the "1.2" "availability_date > Date Restriction" to "6"
And I set the field "Direction" in the "1.2" "availability_date > Date Restriction" to "until"
And I click on "Add restriction..." "button" in the "1" "core_availability > Availability Button Area"
And I click on "Date" "button" in the "Add restriction..." "dialogue"
And I set the field "year" in the "1.3" "availability_date > Date Restriction" to "2023"
And I set the field "Month" in the "1.3" "availability_date > Date Restriction" to "April"
And I set the field "day" in the "1.3" "availability_date > Date Restriction" to "6"
And I set the field "Direction" in the "1.3" "availability_date > Date Restriction" to "from"
When I set the field "Required restrictions" in the "1" "core_availability > Set Of Restrictions" to "any"
Then I should not see "Conflicts with other date restrictions"
Scenario: There should a conflicting availability dates are in the same subset separated by "all".
Given I am on the PageName1 "page activity editing" page logged in as teacher1
And I expand all fieldsets
# Root level: Student "must" match the following.
And I click on "Add restriction..." "button" in the "root" "core_availability > Availability Button Area"
And I click on "Restriction set" "button" in the "Add restriction..." "dialogue"
# This is the second level: Student "must" match any of the following.
And I click on "Add restriction..." "button" in the "1" "core_availability > Availability Button Area"
And I click on "Restriction set" "button" in the "Add restriction..." "dialogue"
# And now the third and final level.
And I click on "Add restriction..." "button" in the "1.1" "core_availability > Availability Button Area"
And I click on "Date" "button" in the "Add restriction..." "dialogue"
And I set the field "year" in the "1.1.1" "availability_date > Date Restriction" to "2023"
And I set the field "Month" in the "1.1.1" "availability_date > Date Restriction" to "April"
And I set the field "day" in the "1.1.1" "availability_date > Date Restriction" to "2"
And I set the field "Direction" in the "1.1.1" "availability_date > Date Restriction" to "from"
And I click on "Add restriction..." "button" in the "1.1" "core_availability > Availability Button Area"
And I click on "Date" "button" in the "Add restriction..." "dialogue"
And I set the field "year" in the "1.1.2" "availability_date > Date Restriction" to "2023"
And I set the field "Month" in the "1.1.2" "availability_date > Date Restriction" to "April"
And I set the field "day" in the "1.1.2" "availability_date > Date Restriction" to "3"
And I set the field "Direction" in the "1.1.2" "availability_date > Date Restriction" to "until"
# Then add a restriction to the second level.
And I click on "Add restriction..." "button" in the "1" "core_availability > Availability Button Area"
And I click on "Restriction set" "button" in the "Add restriction..." "dialogue"
And I click on "Add restriction..." "button" in the "1.2" "core_availability > Availability Button Area"
And I click on "Date" "button" in the "Add restriction..." "dialogue"
And I set the field "year" in the "1.2.1" "availability_date > Date Restriction" to "2023"
And I set the field "Month" in the "1.2.1" "availability_date > Date Restriction" to "April"
And I set the field "day" in the "1.2.1" "availability_date > Date Restriction" to "4"
And I set the field "Direction" in the "1.2.1" "availability_date > Date Restriction" to "from"
And I click on "Add restriction..." "button" in the "1.2" "core_availability > Availability Button Area"
And I click on "Date" "button" in the "Add restriction..." "dialogue"
And I set the field "year" in the "1.2.2" "availability_date > Date Restriction" to "2023"
And I set the field "Month" in the "1.2.2" "availability_date > Date Restriction" to "April"
And I set the field "day" in the "1.2.2" "availability_date > Date Restriction" to "3"
When I set the field "Direction" in the "1.2.2" "availability_date > Date Restriction" to "until"
# Same subset, we can detect conflicts.
Then I should see "Conflicts with other date restrictions"
@@ -0,0 +1,39 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
use Behat\Mink\Element\NodeElement;
/**
* Behat availabilty-related steps definitions.
*
* @package availability_date
* @category test
* @copyright 2023 Laurent David <laurent.david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class behat_availability_date extends behat_base {
/**
* Return the list of partial named selectors.
*
* @return array
*/
public static function get_partial_named_selectors(): array {
return [
new behat_component_named_selector(
'Date Restriction', ["//div[h3[@data-restriction-order=%locator% and contains(text(), 'Date restriction')]]"]
),
];
}
}
@@ -0,0 +1,280 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace availability_date;
use core_availability\tree;
/**
* 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 condition_test extends \advanced_testcase {
/**
* Load required classes.
*/
public function setUp(): void {
// 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(): void {
global $SITE, $USER, $CFG;
$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.)
$this->setTimezone('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->assertMatchesRegularExpression('~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(): void {
// No parameters.
$structure = (object)array();
try {
$date = new condition($structure);
$this->fail();
} catch (\coding_exception $e) {
$this->assertStringContainsString('Missing or invalid ->d', $e->getMessage());
}
// Invalid ->d.
$structure->d = 'woo hah!!';
try {
$date = new condition($structure);
$this->fail();
} catch (\coding_exception $e) {
$this->assertStringContainsString('Missing or invalid ->d', $e->getMessage());
}
// Missing ->t.
$structure->d = '>=';
try {
$date = new condition($structure);
$this->fail();
} catch (\coding_exception $e) {
$this->assertStringContainsString('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->assertStringContainsString('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(): void {
$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(): void {
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(): void {
global $SITE, $CFG;
$this->resetAfterTest();
$this->setTimezone('UTC');
$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->assertMatchesRegularExpression('~after.*18 February 2014, 2:55 (PM|pm)~', $information);
$information = $date->get_description(true, true, $info);
$this->assertMatchesRegularExpression('~before.*18 February 2014, 2:55 (PM|pm)~', $information);
$information = $date->get_standalone_description(true, false, $info);
$this->assertMatchesRegularExpression('~from.*18 February 2014, 2:55 (PM|pm)~', $information);
$information = $date->get_standalone_description(true, true, $info);
$this->assertMatchesRegularExpression('~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->assertMatchesRegularExpression('~before.*18 February 2014, 2:55 (PM|pm)~', $information);
$information = $date->get_description(true, true, $info);
$this->assertMatchesRegularExpression('~after.*18 February 2014, 2:55 (PM|pm)~', $information);
$information = $date->get_standalone_description(true, false, $info);
$this->assertMatchesRegularExpression('~until.*18 February 2014, 2:55 (PM|pm)~', $information);
$information = $date->get_standalone_description(true, true, $info);
$this->assertMatchesRegularExpression('~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->assertMatchesRegularExpression('~on or after.*5 March 2014([^0-9]*)$~', $information);
$information = $date->get_description(true, true, $info);
$this->assertMatchesRegularExpression('~before.*end of.*4 March 2014([^0-9]*)$~', $information);
$information = $date->get_standalone_description(true, false, $info);
$this->assertMatchesRegularExpression('~from.*5 March 2014([^0-9]*)$~', $information);
$information = $date->get_standalone_description(true, true, $info);
$this->assertMatchesRegularExpression('~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->assertMatchesRegularExpression('~before end of.*4 March 2014([^0-9]*)$~', $information);
$information = $date->get_description(true, true, $info);
$this->assertMatchesRegularExpression('~on or after.*5 March 2014([^0-9]*)$~', $information);
$information = $date->get_standalone_description(true, false, $info);
$this->assertMatchesRegularExpression('~until end of.*4 March 2014([^0-9]*)$~', $information);
$information = $date->get_standalone_description(true, true, $info);
$this->assertMatchesRegularExpression('~from.*5 March 2014([^0-9]*)$~', $information);
}
/**
* Tests the update_all_dates function.
*/
public function test_update_all_dates(): void {
global $DB;
$this->resetAfterTest();
// Create a course with 3 pages.
$generator = $this->getDataGenerator();
$course = $generator->create_course();
$rec = array('course' => $course);
$page1 = $generator->get_plugin_generator('mod_page')->create_instance($rec);
$page2 = $generator->get_plugin_generator('mod_page')->create_instance($rec);
$page3 = $generator->get_plugin_generator('mod_page')->create_instance($rec);
// Set the availability page 2 to a simple date condition. You can access
// it from 1337 onwards.
$simplecondition = tree::get_root_json(array(
condition::get_json(condition::DIRECTION_FROM, 1337)));
$DB->set_field('course_modules', 'availability',
json_encode($simplecondition), array('id' => $page2->cmid));
// Set page 3 to a complex set of conditions including a nested date condition.
// You can access it until 1459, *or* after 2810 if you belong to a group.
$complexcondition = tree::get_root_json(array(
condition::get_json(condition::DIRECTION_UNTIL, 1459),
tree::get_nested_json(array(
condition::get_json(condition::DIRECTION_FROM, 2810),
\availability_group\condition::get_json()))),
tree::OP_OR);
$DB->set_field('course_modules', 'availability',
json_encode($complexcondition), array('id' => $page3->cmid));
// Now use the update_all_dates function to move date forward 100000.
condition::update_all_dates($course->id, 100000);
// Get the expected conditions after adjusting time, and compare to database.
$simplecondition->c[0]->t = 101337;
$complexcondition->c[0]->t = 101459;
$complexcondition->c[1]->c[0]->t = 102810;
$this->assertEquals($simplecondition, json_decode(
$DB->get_field('course_modules', 'availability', array('id' => $page2->cmid))));
$this->assertEquals($complexcondition, json_decode(
$DB->get_field('course_modules', 'availability', array('id' => $page3->cmid))));
// The one without availability conditions should still be null.
$this->assertNull($DB->get_field('course_modules', 'availability', array('id' => $page1->cmid)));
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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 = 2024042200;
$plugin->requires = 2024041600;
$plugin->component = 'availability_date';
@@ -0,0 +1,241 @@
YUI.add('moodle-availability_date-form', function (Y, NAME) {
/**
* 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 html = '<span class="col-form-label pr-3">' +
M.util.get_string('direction_before', 'availability_date') + '</span> <span class="availability-group">' +
'<label><span class="accesshide">' + M.util.get_string('direction_label', 'availability_date') + ' </span>' +
'<select name="direction" class="custom-select">' +
'<option value="&gt;=">' + M.util.get_string('direction_from', 'availability_date') + '</option>' +
'<option value="&lt;">' + M.util.get_string('direction_until', 'availability_date') + '</option>' +
'</select></label></span> ' + this.html;
var node = Y.Node.create('<span>' + html + '</span>');
// 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.util.get_string('ajaxerror', 'availability_date'));
}
}});
} else {
// Set default time that corresponds to the HTML selectors.
node.setData('time', this.defaultTime);
}
if (json.nodeUID === undefined) {
var miliTime = new Date();
json.nodeUID = miliTime.getTime();
}
node.setData('nodeUID', json.nodeUID);
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('.availability-field');
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} node 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.util.get_string('ajaxerror', 'availability_date'));
}
}});
};
M.availability_date.form.fillValue = function(value, node) {
value.d = node.one('select[name=direction]').get('value');
value.t = parseInt(node.getData('time'), 10);
value.nodeUID = node.getData('nodeUID');
};
/**
* List out Date node value in the same branch.
*
* This will go through all array node and list nodes that are sibling of the current node.
*
* @method findAllDateSiblings
* @param {Array} tree Tree items to convert
* @param {Number} nodeUIDToFind node UID to find.
* @return {Array|null} array of surrounding date avaiability values
*/
M.availability_date.form.findAllDateSiblings = function(tree, nodeUIDToFind) {
var itemValue = null;
var siblingsFinderRecursive = function(itemsTree) {
var dateSiblings = [];
var nodeFound = false;
var index;
var childDates;
var currentOp = itemsTree.op !== undefined ? itemsTree.op : null;
if (itemsTree.c !== undefined) {
var children = itemsTree.c;
for (index = 0; index < children.length; index++) {
itemValue = children.at(index);
if (itemValue.type === undefined) {
childDates = siblingsFinderRecursive(itemValue);
if (childDates) {
return childDates;
}
}
if (itemValue.type === 'date') {
// We go through all tree node, if we meet the current node then we add all nodes in the current branch.
if (nodeUIDToFind === itemValue.nodeUID) {
nodeFound = true;
} else if (currentOp === '&') {
dateSiblings.push(itemValue);
}
}
}
if (nodeFound) {
return dateSiblings;
}
}
return null;
};
return siblingsFinderRecursive(tree);
};
/**
* Check current node.
*
* This will check current date node with all date node in tree node.
*
* @method checkConditionDate
* @param {Y.Node} currentNode The curent node.
*
* @return {boolean} error Return true if the date is conflict.
*/
M.availability_date.form.checkConditionDate = function(currentNode) {
var error = false;
var currentNodeUID = currentNode.getData('nodeUID');
var currentNodeDirection = currentNode.one('select[name=direction]').get('value');
var currentNodeTime = parseInt(currentNode.getData('time'), 10);
var dateSiblings = M.availability_date.form.findAllDateSiblings(
M.core_availability.form.rootList.getValue(),
currentNodeUID);
if (dateSiblings) {
dateSiblings.forEach(function(dateSibling) {
// Validate if the date is conflict.
if (dateSibling.d === '<') {
if (currentNodeDirection === '>=' && currentNodeTime >= dateSibling.t) {
error = true;
}
} else {
if (currentNodeDirection === '<' && currentNodeTime <= dateSibling.t) {
error = true;
}
}
return error;
});
}
return error;
};
M.availability_date.form.fillErrors = function(errors, node) {
var error = M.availability_date.form.checkConditionDate(node);
if (error) {
errors.push('availability_date:error_dateconflict');
}
};
}, '@VERSION@', {"requires": ["base", "node", "event", "io", "moodle-core_availability-form"]});
@@ -0,0 +1 @@
YUI.add("moodle-availability_date-form",function(o,e){M.availability_date=M.availability_date||{},M.availability_date.form=o.Object(M.core_availability.plugin),M.availability_date.form.initInner=function(e,a){this.html=e,this.defaultTime=a},M.availability_date.form.getNode=function(e){var t,i,a='<span class="col-form-label pr-3">'+M.util.get_string("direction_before","availability_date")+'</span> <span class="availability-group"><label><span class="accesshide">'+M.util.get_string("direction_label","availability_date")+' </span><select name="direction" class="custom-select"><option value="&gt;=">'+M.util.get_string("direction_from","availability_date")+'</option><option value="&lt;">'+M.util.get_string("direction_until","availability_date")+"</option></select></label></span> "+this.html,l=o.Node.create("<span>"+a+"</span>");return e.t!==undefined?(l.setData("time",e.t),l.all("select:not([name=direction])").each(function(e){e.set("disabled",!0)}),a=M.cfg.wwwroot+"/availability/condition/date/ajax.php?action=fromtime&time="+e.t,o.io(a,{on:{success:function(e,a){var t,i,n=o.JSON.parse(a.responseText);for(t in n)(i=l.one("select[name=x\\["+t+"\\]]")).set("value",""+n[t]),i.set("disabled",!1)},failure:function(){window.alert(M.util.get_string("ajaxerror","availability_date"))}}})):l.setData("time",this.defaultTime),e.nodeUID===undefined&&(a=new Date,e.nodeUID=a.getTime()),l.setData("nodeUID",e.nodeUID),e.d!==undefined&&l.one("select[name=direction]").set("value",e.d),M.availability_date.form.addedEvents||(M.availability_date.form.addedEvents=!0,(a=o.one(".availability-field")).delegate("change",function(){M.core_availability.form.update()},".availability_date select[name=direction]"),a.delegate("change",function(){M.availability_date.form.updateTime(this.ancestor("span.availability_date"))},".availability_date select:not([name=direction])")),l.one("a[href=#]")&&(M.form.dateselector.init_single_date_selector(l),t=l.one("select[name=x\\[year\\]]"),i=t.set,t.set=function(e,a){i.call(t,e,a),"selectedIndex"===e&&setTimeout(function(){M.availability_date.form.updateTime(l)},0)}),l},M.availability_date.form.updateTime=function(t){var e=M.cfg.wwwroot+"/availability/condition/date/ajax.php?action=totime&year="+t.one("select[name=x\\[year\\]]").get("value")+"&month="+t.one("select[name=x\\[month\\]]").get("value")+"&day="+t.one("select[name=x\\[day\\]]").get("value")+"&hour="+t.one("select[name=x\\[hour\\]]").get("value")+"&minute="+t.one("select[name=x\\[minute\\]]").get("value");o.io(e,{on:{success:function(e,a){t.setData("time",a.responseText),M.core_availability.form.update()},failure:function(){window.alert(M.util.get_string("ajaxerror","availability_date"))}}})},M.availability_date.form.fillValue=function(e,a){e.d=a.one("select[name=direction]").get("value"),e.t=parseInt(a.getData("time"),10),e.nodeUID=a.getData("nodeUID")},M.availability_date.form.findAllDateSiblings=function(e,d){var r,c=function(e){var a,t,i,n=[],l=!1,o=e.op!==undefined?e.op:null;if(e.c!==undefined){for(i=e.c,a=0;a<i.length;a++){if((r=i.at(a)).type===undefined&&(t=c(r)))return t;"date"===r.type&&(d===r.nodeUID?l=!0:"&"===o&&n.push(r))}if(l)return n}return null};return c(e)},M.availability_date.form.checkConditionDate=function(e){var a=!1,t=e.getData("nodeUID"),i=e.one("select[name=direction]").get("value"),n=parseInt(e.getData("time"),10),e=M.availability_date.form.findAllDateSiblings(M.core_availability.form.rootList.getValue(),t);return e&&e.forEach(function(e){return"<"===e.d?">="===i&&n>=e.t&&(a=!0):"<"===i&&n<=e.t&&(a=!0),a}),a},M.availability_date.form.fillErrors=function(e,a){M.availability_date.form.checkConditionDate(a)&&e.push("availability_date:error_dateconflict")}},"@VERSION@",{requires:["base","node","event","io","moodle-core_availability-form"]});
@@ -0,0 +1,241 @@
YUI.add('moodle-availability_date-form', function (Y, NAME) {
/**
* 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 html = '<span class="col-form-label pr-3">' +
M.util.get_string('direction_before', 'availability_date') + '</span> <span class="availability-group">' +
'<label><span class="accesshide">' + M.util.get_string('direction_label', 'availability_date') + ' </span>' +
'<select name="direction" class="custom-select">' +
'<option value="&gt;=">' + M.util.get_string('direction_from', 'availability_date') + '</option>' +
'<option value="&lt;">' + M.util.get_string('direction_until', 'availability_date') + '</option>' +
'</select></label></span> ' + this.html;
var node = Y.Node.create('<span>' + html + '</span>');
// 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.util.get_string('ajaxerror', 'availability_date'));
}
}});
} else {
// Set default time that corresponds to the HTML selectors.
node.setData('time', this.defaultTime);
}
if (json.nodeUID === undefined) {
var miliTime = new Date();
json.nodeUID = miliTime.getTime();
}
node.setData('nodeUID', json.nodeUID);
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('.availability-field');
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} node 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.util.get_string('ajaxerror', 'availability_date'));
}
}});
};
M.availability_date.form.fillValue = function(value, node) {
value.d = node.one('select[name=direction]').get('value');
value.t = parseInt(node.getData('time'), 10);
value.nodeUID = node.getData('nodeUID');
};
/**
* List out Date node value in the same branch.
*
* This will go through all array node and list nodes that are sibling of the current node.
*
* @method findAllDateSiblings
* @param {Array} tree Tree items to convert
* @param {Number} nodeUIDToFind node UID to find.
* @return {Array|null} array of surrounding date avaiability values
*/
M.availability_date.form.findAllDateSiblings = function(tree, nodeUIDToFind) {
var itemValue = null;
var siblingsFinderRecursive = function(itemsTree) {
var dateSiblings = [];
var nodeFound = false;
var index;
var childDates;
var currentOp = itemsTree.op !== undefined ? itemsTree.op : null;
if (itemsTree.c !== undefined) {
var children = itemsTree.c;
for (index = 0; index < children.length; index++) {
itemValue = children.at(index);
if (itemValue.type === undefined) {
childDates = siblingsFinderRecursive(itemValue);
if (childDates) {
return childDates;
}
}
if (itemValue.type === 'date') {
// We go through all tree node, if we meet the current node then we add all nodes in the current branch.
if (nodeUIDToFind === itemValue.nodeUID) {
nodeFound = true;
} else if (currentOp === '&') {
dateSiblings.push(itemValue);
}
}
}
if (nodeFound) {
return dateSiblings;
}
}
return null;
};
return siblingsFinderRecursive(tree);
};
/**
* Check current node.
*
* This will check current date node with all date node in tree node.
*
* @method checkConditionDate
* @param {Y.Node} currentNode The curent node.
*
* @return {boolean} error Return true if the date is conflict.
*/
M.availability_date.form.checkConditionDate = function(currentNode) {
var error = false;
var currentNodeUID = currentNode.getData('nodeUID');
var currentNodeDirection = currentNode.one('select[name=direction]').get('value');
var currentNodeTime = parseInt(currentNode.getData('time'), 10);
var dateSiblings = M.availability_date.form.findAllDateSiblings(
M.core_availability.form.rootList.getValue(),
currentNodeUID);
if (dateSiblings) {
dateSiblings.forEach(function(dateSibling) {
// Validate if the date is conflict.
if (dateSibling.d === '<') {
if (currentNodeDirection === '>=' && currentNodeTime >= dateSibling.t) {
error = true;
}
} else {
if (currentNodeDirection === '<' && currentNodeTime <= dateSibling.t) {
error = true;
}
}
return error;
});
}
return error;
};
M.availability_date.form.fillErrors = function(errors, node) {
var error = M.availability_date.form.checkConditionDate(node);
if (error) {
errors.push('availability_date:error_dateconflict');
}
};
}, '@VERSION@', {"requires": ["base", "node", "event", "io", "moodle-core_availability-form"]});
@@ -0,0 +1,10 @@
{
"name": "moodle-availability_date-form",
"builds": {
"moodle-availability_date-form": {
"jsfiles": [
"form.js"
]
}
}
}
+236
View File
@@ -0,0 +1,236 @@
/**
* 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 html = '<span class="col-form-label pr-3">' +
M.util.get_string('direction_before', 'availability_date') + '</span> <span class="availability-group">' +
'<label><span class="accesshide">' + M.util.get_string('direction_label', 'availability_date') + ' </span>' +
'<select name="direction" class="custom-select">' +
'<option value="&gt;=">' + M.util.get_string('direction_from', 'availability_date') + '</option>' +
'<option value="&lt;">' + M.util.get_string('direction_until', 'availability_date') + '</option>' +
'</select></label></span> ' + this.html;
var node = Y.Node.create('<span>' + html + '</span>');
// 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.util.get_string('ajaxerror', 'availability_date'));
}
}});
} else {
// Set default time that corresponds to the HTML selectors.
node.setData('time', this.defaultTime);
}
if (json.nodeUID === undefined) {
var miliTime = new Date();
json.nodeUID = miliTime.getTime();
}
node.setData('nodeUID', json.nodeUID);
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('.availability-field');
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} node 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.util.get_string('ajaxerror', 'availability_date'));
}
}});
};
M.availability_date.form.fillValue = function(value, node) {
value.d = node.one('select[name=direction]').get('value');
value.t = parseInt(node.getData('time'), 10);
value.nodeUID = node.getData('nodeUID');
};
/**
* List out Date node value in the same branch.
*
* This will go through all array node and list nodes that are sibling of the current node.
*
* @method findAllDateSiblings
* @param {Array} tree Tree items to convert
* @param {Number} nodeUIDToFind node UID to find.
* @return {Array|null} array of surrounding date avaiability values
*/
M.availability_date.form.findAllDateSiblings = function(tree, nodeUIDToFind) {
var itemValue = null;
var siblingsFinderRecursive = function(itemsTree) {
var dateSiblings = [];
var nodeFound = false;
var index;
var childDates;
var currentOp = itemsTree.op !== undefined ? itemsTree.op : null;
if (itemsTree.c !== undefined) {
var children = itemsTree.c;
for (index = 0; index < children.length; index++) {
itemValue = children.at(index);
if (itemValue.type === undefined) {
childDates = siblingsFinderRecursive(itemValue);
if (childDates) {
return childDates;
}
}
if (itemValue.type === 'date') {
// We go through all tree node, if we meet the current node then we add all nodes in the current branch.
if (nodeUIDToFind === itemValue.nodeUID) {
nodeFound = true;
} else if (currentOp === '&') {
dateSiblings.push(itemValue);
}
}
}
if (nodeFound) {
return dateSiblings;
}
}
return null;
};
return siblingsFinderRecursive(tree);
};
/**
* Check current node.
*
* This will check current date node with all date node in tree node.
*
* @method checkConditionDate
* @param {Y.Node} currentNode The curent node.
*
* @return {boolean} error Return true if the date is conflict.
*/
M.availability_date.form.checkConditionDate = function(currentNode) {
var error = false;
var currentNodeUID = currentNode.getData('nodeUID');
var currentNodeDirection = currentNode.one('select[name=direction]').get('value');
var currentNodeTime = parseInt(currentNode.getData('time'), 10);
var dateSiblings = M.availability_date.form.findAllDateSiblings(
M.core_availability.form.rootList.getValue(),
currentNodeUID);
if (dateSiblings) {
dateSiblings.forEach(function(dateSibling) {
// Validate if the date is conflict.
if (dateSibling.d === '<') {
if (currentNodeDirection === '>=' && currentNodeTime >= dateSibling.t) {
error = true;
}
} else {
if (currentNodeDirection === '<' && currentNodeTime <= dateSibling.t) {
error = true;
}
}
return error;
});
}
return error;
};
M.availability_date.form.fillErrors = function(errors, node) {
var error = M.availability_date.form.checkConditionDate(node);
if (error) {
errors.push('availability_date:error_dateconflict');
}
};
@@ -0,0 +1,11 @@
{
"moodle-availability_date-form": {
"requires": [
"base",
"node",
"event",
"io",
"moodle-core_availability-form"
]
}
}
@@ -0,0 +1,57 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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);
}
}
@@ -0,0 +1,319 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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;
}
/**
* Returns a JSON object which corresponds to a condition of this type.
*
* Intended for unit testing, as normally the JSON values are constructed
* by JavaScript code.
*
* @param int $gradeitemid Grade item id
* @param number|null $min Min grade (or null if no min)
* @param number|null $max Max grade (or null if no max)
* @return stdClass Object representing condition
*/
public static function get_json($gradeitemid, $min = null, $max = null) {
$result = (object)array('type' => 'grade', 'id' => (int)$gradeitemid);
if (!is_null($min)) {
$result->min = $min;
}
if (!is_null($max)) {
$result->max = $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';
}
}
// We cannot get the name at this point because it requires format_string which is not
// allowed here. Instead, get it later with the callback function below.
$name = $this->description_callback([$this->gradeitemid]);
return get_string('requires_' . $string, 'availability_grade', $name);
}
/**
* Gets the grade name at display time.
*
* @param \course_modinfo $modinfo Modinfo
* @param \context $context Context
* @param string[] $params Parameters (just grade item id)
* @return string Text value
*/
public static function get_description_callback_value(
\course_modinfo $modinfo, \context $context, array $params): string {
if (count($params) !== 1 || !is_number($params[0])) {
return '<!-- Invalid grade description callback -->';
}
$gradeitemid = (int)$params[0];
return self::get_cached_grade_name($modinfo->get_course_id(), $gradeitemid);
}
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) {
// This function produces division by zero error warnings when rawgrademax and rawgrademin
// are equal. Below change does not affect function behavior, just avoids the warning.
if (is_null($record->finalgrade) || $record->rawgrademax == $record->rawgrademin) {
// 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));
// This function produces division by zero error warnings when rawgrademax and rawgrademin
// are equal. Below change does not affect function behavior, just avoids the warning.
if ($record && !is_null($record->finalgrade) && $record->rawgrademax != $record->rawgrademin) {
$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;
}
}
}
@@ -0,0 +1,74 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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');
require_once($CFG->dirroot . '/course/lib.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) {
// Don't include the grade item if it's linked with a module that is being deleted.
if (course_module_instance_pending_deletion($item->courseid, $item->itemmodule, $item->iteminstance)) {
continue;
}
// 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(true);
}
\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);
}
}
@@ -0,0 +1,46 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Privacy Subsystem implementation for availability_grade.
*
* @package availability_grade
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace availability_grade\privacy;
defined('MOODLE_INTERNAL') || die();
/**
* Privacy Subsystem for availability_grade implementing null_provider.
*
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider implements \core_privacy\local\metadata\null_provider {
/**
* Get the language string identifier with the component's language
* file to explain why this plugin stores no data.
*
* @return string
*/
public static function get_reason(): string {
return 'privacy:metadata';
}
}
@@ -0,0 +1,40 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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,
),
);
@@ -0,0 +1,44 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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 &#x2265;';
$string['option_max'] = 'must be <';
$string['pluginname'] = 'Restriction by grades';
$string['requires_any'] = 'You have a grade in <strong>{$a}</strong>';
$string['requires_max'] = 'You achieve lower than a certain score in <strong>{$a}</strong>';
$string['requires_min'] = 'You achieve higher than a certain score in <strong>{$a}</strong>';
$string['requires_notany'] = 'You do not have a grade in <strong>{$a}</strong>';
$string['requires_notgeneral'] = 'You do not get certain scores in <strong>{$a}</strong>';
$string['requires_range'] = 'You achieve a score within a certain range in <strong>{$a}</strong>';
$string['missing'] = '(missing activity)';
$string['title'] = 'Grade';
$string['privacy:metadata'] = 'The Restriction by grades plugin does not store any personal data.';
@@ -0,0 +1,136 @@
@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.com |
| student1 | s@example.com |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
# Add an assignment.
And the following "activities" exist:
| activity | course | name | assignsubmission_onlinetext_enabled |
| assign | C1 | A1 | 1 |
| page | C1 | P1 | |
| page | C1 | P2 | |
| page | C1 | P3 | |
| page | C1 | P4 | |
@javascript
Scenario: Test condition
Given I am on the "P2" "page activity editing" page logged in as "teacher1"
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 am on the "P3" "page activity editing" page
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 click on "max" "checkbox" in the ".availability-item" "css_element"
And I set the field "Maximum grade percentage (exclusive)" to "80"
And I press "Save and return to course"
# Check if disabling a part of the restriction is get saved.
And I am on the "P3" "page activity editing" page
And I expand all fieldsets
And I click on "max" "checkbox" in the ".availability-item" "css_element"
And I press "Save and return to course"
And I am on the "P3" "page activity editing" page
And the field "Maximum grade percentage (exclusive)" matches value ""
And I am on "Course 1" course homepage
# Add a Page with a grade condition for 10%.
And I am on the "P4" "page activity editing" page
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 am on the "A1" "assign activity" page logged in as student1
# Do the assignment.
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 am on "Course 1" course homepage
# 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 am on the "A1" "assign activity" page logged in as teacher1
# Give the assignment 40%.
And I follow "View all submissions"
# Pick the grade link in the row that has s@example.com in it.
And I click on "Grade" "link" in the "s@example.com" "table_row"
And I set the field "Grade out of 100" to "40"
And I click on "Save changes" "button"
And I click on "Edit settings" "link"
And I log out
# Log back in as student.
And I am on the "Course 1" course page logged in as student1
# 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"
@javascript
Scenario: Condition display with filters
# Teacher sets up a restriction on group G1, using multilang filter.
Given the following "activity" exists:
| activity | assign |
| name | <span lang="en" class="multilang">A-One</span><span lang="fr" class="multilang">A-Un</span> |
| intro | Test |
| course | C1 |
| idnumber | 0001 |
| section | 1 |
And the "multilang" filter is "on"
And the "multilang" filter applies to "content and headings"
# The activity names filter is enabled because it triggered a bug in older versions.
And the "activitynames" filter is "on"
And the "activitynames" filter applies to "content and headings"
And I am on the "C1" "Course" page logged in as "teacher1"
And I turn editing mode on
And I am on the "P1" "page activity editing" page
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 set the field "Grade" to "A-One"
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"
And I log out
# Student sees information about no access to group, with group name in correct language.
When I am on the "C1" "Course" page logged in as "student1"
Then I should see "Not available unless: You achieve higher than a certain score in A-One"
And I should not see "A-Un"
@@ -0,0 +1,246 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace availability_grade;
/**
* 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 condition_test extends \advanced_testcase {
/**
* Tests constructing and using grade condition.
*/
public function test_usage(): void {
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);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~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);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~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);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~achieve higher than.*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);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~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);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~achieve lower than a certain score in.*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);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~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);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~achieve a score within a certain range.*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);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~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);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~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(): void {
// No parameters.
$structure = new \stdClass();
try {
$cond = new condition($structure);
$this->fail();
} catch (\coding_exception $e) {
$this->assertStringContainsString('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->assertStringContainsString('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->assertStringContainsString('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->assertStringContainsString('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(): void {
$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(): void {
$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);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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 = 2024042200;
$plugin->requires = 2024041600;
$plugin->component = 'availability_grade';
@@ -0,0 +1,167 @@
YUI.add('moodle-availability_grade-form', function (Y, NAME) {
/**
* 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 html = '<label class="mb-3"><span class="pr-3">' + M.util.get_string('title', 'availability_grade') + '</span> ' +
'<span class="availability-group">' +
'<select name="id" class="custom-select"><option value="0">' + M.util.get_string('choosedots', 'moodle') + '</option>';
for (var i = 0; i < this.grades.length; i++) {
var grade = this.grades[i];
// String has already been escaped using format_string.
html += '<option value="' + grade.id + '">' + grade.name + '</option>';
}
html += '</select></span></label> <br><span class="availability-group mb-3">' +
'<label><input type="checkbox" class="form-check-input position-static mt-0 mx-1" name="min"/>' +
M.util.get_string('option_min', 'availability_grade') +
'</label> <label><span class="accesshide">' + M.util.get_string('label_min', 'availability_grade') +
'</span><input type="text" class="form-control mx-1" name="minval" title="' +
M.util.get_string('label_min', 'availability_grade') + '"/></label>%</span><br>' +
'<span class="availability-group mb-3">' +
'<label><input type="checkbox" class="form-check-input position-static mt-0 mx-1" name="max"/>' +
M.util.get_string('option_max', 'availability_grade') +
'</label> <label><span class="accesshide">' + M.util.get_string('label_max', 'availability_grade') +
'</span><input type="text" class="form-control mx-1" name="maxval" title="' +
M.util.get_string('label_max', 'availability_grade') + '"/></label>%</span>';
var node = Y.Node.create('<div class="d-inline-block d-flex flex-wrap align-items-center">' + html + '</div>');
// 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('.availability-field');
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);
M.core_availability.form.update();
}, '.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');
}
};
}, '@VERSION@', {"requires": ["base", "node", "event", "moodle-core_availability-form"]});
@@ -0,0 +1 @@
YUI.add("moodle-availability_grade-form",function(r,a){M.availability_grade=M.availability_grade||{},M.availability_grade.form=r.Object(M.core_availability.plugin),M.availability_grade.form.grades=null,M.availability_grade.form.initInner=function(a){this.grades=a,this.nodesSoFar=0},M.availability_grade.form.getNode=function(a){var e,i,l,t,n;for(this.nodesSoFar++,e='<label class="mb-3"><span class="pr-3">'+M.util.get_string("title","availability_grade")+'</span> <span class="availability-group"><select name="id" class="custom-select"><option value="0">'+M.util.get_string("choosedots","moodle")+"</option>",i=0;i<this.grades.length;i++)e+='<option value="'+(l=this.grades[i]).id+'">'+l.name+"</option>";return e+='</select></span></label> <br><span class="availability-group mb-3"><label><input type="checkbox" class="form-check-input position-static mt-0 mx-1" name="min"/>'+M.util.get_string("option_min","availability_grade")+'</label> <label><span class="accesshide">'+M.util.get_string("label_min","availability_grade")+'</span><input type="text" class="form-control mx-1" name="minval" title="'+M.util.get_string("label_min","availability_grade")+'"/></label>%</span><br><span class="availability-group mb-3"><label><input type="checkbox" class="form-check-input position-static mt-0 mx-1" name="max"/>'+M.util.get_string("option_max","availability_grade")+'</label> <label><span class="accesshide">'+M.util.get_string("label_max","availability_grade")+'</span><input type="text" class="form-control mx-1" name="maxval" title="'+M.util.get_string("label_max","availability_grade")+'"/></label>%</span>',t=r.Node.create('<div class="d-inline-block d-flex flex-wrap align-items-center">'+e+"</div>"),a.id!==undefined&&t.one("select[name=id] > option[value="+a.id+"]")&&t.one("select[name=id]").set("value",""+a.id),a.min!==undefined&&(t.one("input[name=min]").set("checked",!0),t.one("input[name=minval]").set("value",a.min)),a.max!==undefined&&(t.one("input[name=max]").set("checked",!0),t.one("input[name=maxval]").set("value",a.max)),n=function(a,e){var i=a.ancestor("label").next("label").one("input"),a=a.get("checked");return i.set("disabled",!a),e&&a&&i.focus(),a},t.all("input[type=checkbox]").each(n),M.availability_grade.form.addedEvents||(M.availability_grade.form.addedEvents=!0,(a=r.one(".availability-field")).delegate("change",function(){M.core_availability.form.update()},".availability_grade select[name=id]"),a.delegate("click",function(){n(this,!0),M.core_availability.form.update()},".availability_grade input[type=checkbox]"),a.delegate("valuechange",function(){M.core_availability.form.update()},".availability_grade input[type=text]")),t},M.availability_grade.form.fillValue=function(a,e){a.id=parseInt(e.one("select[name=id]").get("value"),10),e.one("input[name=min]").get("checked")&&(a.min=this.getValue("minval",e)),e.one("input[name=max]").get("checked")&&(a.max=this.getValue("maxval",e))},M.availability_grade.form.getValue=function(a,e){a=e.one("input[name="+a+"]").get("value");return!/^[0-9]+([.,][0-9]+)?$/.test(a)||(e=parseFloat(a.replace(",",".")))<0||100<e?a:e},M.availability_grade.form.fillErrors=function(a,e){var i={};this.fillValue(i,e),0===i.id&&a.push("availability_grade:error_selectgradeid"),i.min!==undefined&&"string"==typeof i.min||i.max!==undefined&&"string"==typeof i.max?a.push("availability_grade:error_invalidnumber"):i.min!==undefined&&i.max!==undefined&&i.max<=i.min&&a.push("availability_grade:error_backwardrange")}},"@VERSION@",{requires:["base","node","event","moodle-core_availability-form"]});
@@ -0,0 +1,167 @@
YUI.add('moodle-availability_grade-form', function (Y, NAME) {
/**
* 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 html = '<label class="mb-3"><span class="pr-3">' + M.util.get_string('title', 'availability_grade') + '</span> ' +
'<span class="availability-group">' +
'<select name="id" class="custom-select"><option value="0">' + M.util.get_string('choosedots', 'moodle') + '</option>';
for (var i = 0; i < this.grades.length; i++) {
var grade = this.grades[i];
// String has already been escaped using format_string.
html += '<option value="' + grade.id + '">' + grade.name + '</option>';
}
html += '</select></span></label> <br><span class="availability-group mb-3">' +
'<label><input type="checkbox" class="form-check-input position-static mt-0 mx-1" name="min"/>' +
M.util.get_string('option_min', 'availability_grade') +
'</label> <label><span class="accesshide">' + M.util.get_string('label_min', 'availability_grade') +
'</span><input type="text" class="form-control mx-1" name="minval" title="' +
M.util.get_string('label_min', 'availability_grade') + '"/></label>%</span><br>' +
'<span class="availability-group mb-3">' +
'<label><input type="checkbox" class="form-check-input position-static mt-0 mx-1" name="max"/>' +
M.util.get_string('option_max', 'availability_grade') +
'</label> <label><span class="accesshide">' + M.util.get_string('label_max', 'availability_grade') +
'</span><input type="text" class="form-control mx-1" name="maxval" title="' +
M.util.get_string('label_max', 'availability_grade') + '"/></label>%</span>';
var node = Y.Node.create('<div class="d-inline-block d-flex flex-wrap align-items-center">' + html + '</div>');
// 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('.availability-field');
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);
M.core_availability.form.update();
}, '.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');
}
};
}, '@VERSION@', {"requires": ["base", "node", "event", "moodle-core_availability-form"]});
@@ -0,0 +1,10 @@
{
"name": "moodle-availability_grade-form",
"builds": {
"moodle-availability_grade-form": {
"jsfiles": [
"form.js"
]
}
}
}
+162
View File
@@ -0,0 +1,162 @@
/**
* 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 html = '<label class="mb-3"><span class="pr-3">' + M.util.get_string('title', 'availability_grade') + '</span> ' +
'<span class="availability-group">' +
'<select name="id" class="custom-select"><option value="0">' + M.util.get_string('choosedots', 'moodle') + '</option>';
for (var i = 0; i < this.grades.length; i++) {
var grade = this.grades[i];
// String has already been escaped using format_string.
html += '<option value="' + grade.id + '">' + grade.name + '</option>';
}
html += '</select></span></label> <br><span class="availability-group mb-3">' +
'<label><input type="checkbox" class="form-check-input position-static mt-0 mx-1" name="min"/>' +
M.util.get_string('option_min', 'availability_grade') +
'</label> <label><span class="accesshide">' + M.util.get_string('label_min', 'availability_grade') +
'</span><input type="text" class="form-control mx-1" name="minval" title="' +
M.util.get_string('label_min', 'availability_grade') + '"/></label>%</span><br>' +
'<span class="availability-group mb-3">' +
'<label><input type="checkbox" class="form-check-input position-static mt-0 mx-1" name="max"/>' +
M.util.get_string('option_max', 'availability_grade') +
'</label> <label><span class="accesshide">' + M.util.get_string('label_max', 'availability_grade') +
'</span><input type="text" class="form-control mx-1" name="maxval" title="' +
M.util.get_string('label_max', 'availability_grade') + '"/></label>%</span>';
var node = Y.Node.create('<div class="d-inline-block d-flex flex-wrap align-items-center">' + html + '</div>');
// 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('.availability-field');
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);
M.core_availability.form.update();
}, '.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');
}
};
@@ -0,0 +1,10 @@
{
"moodle-availability_grade-form": {
"requires": [
"base",
"node",
"event",
"moodle-core_availability-form"
]
}
}
@@ -0,0 +1,291 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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_groups(0, $userid);
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 {
// Not safe to call format_string here; use the special function to call it later.
$name = self::description_format_string(self::$groupnames[$this->groupid]);
}
} 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';
}
/**
* Include this condition only if we are including groups in restore, or
* if it's a generic 'same activity' one.
*
* @param int $restoreid The restore Id.
* @param int $courseid The ID of the course.
* @param base_logger $logger The logger being used.
* @param string $name Name of item being restored.
* @param base_task $task The task being performed.
*
* @return Integer groupid
*/
public function include_after_restore($restoreid, $courseid, \base_logger $logger,
$name, \base_task $task) {
return !$this->groupid || $task->get_setting_value('groups');
}
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;
// If the array is empty already, just return it.
if (!$users) {
return $users;
}
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;
}
/**
* Returns a JSON object which corresponds to a condition of this type.
*
* Intended for unit testing, as normally the JSON values are constructed
* by JavaScript code.
*
* @param int $groupid Required group id (0 = any group)
* @return stdClass Object representing condition
*/
public static function get_json($groupid = 0) {
$result = (object)array('type' => 'group');
// Id is only included if set.
if ($groupid) {
$result->id = (int)$groupid;
}
return $result;
}
public function get_user_list_sql($not, \core_availability\info $info, $onlyactive) {
global $DB;
// Get enrolled users with access all groups. These always are allowed.
list($aagsql, $aagparams) = get_enrolled_sql(
$info->get_context(), 'moodle/site:accessallgroups', 0, $onlyactive);
// Get all enrolled users.
list ($enrolsql, $enrolparams) =
get_enrolled_sql($info->get_context(), '', 0, $onlyactive);
// Condition for specified or any group.
$matchparams = array();
if ($this->groupid) {
$matchsql = "SELECT 1
FROM {groups_members} gm
WHERE gm.userid = userids.id
AND gm.groupid = " .
self::unique_sql_parameter($matchparams, $this->groupid);
} else {
$matchsql = "SELECT 1
FROM {groups_members} gm
JOIN {groups} g ON g.id = gm.groupid
WHERE gm.userid = userids.id
AND g.courseid = " .
self::unique_sql_parameter($matchparams, $info->get_course()->id);
}
// Overall query combines all this.
$condition = $not ? 'NOT' : '';
$sql = "SELECT userids.id
FROM ($enrolsql) userids
WHERE (userids.id IN ($aagsql)) OR $condition EXISTS ($matchsql)";
return array($sql, array_merge($enrolparams, $aagparams, $matchparams));
}
}
@@ -0,0 +1,88 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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)),
'visibility' => $rec->visibility
);
}
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;
// Only show this option if there are some groups.
return count($this->get_all_groups($course->id)) > 0;
}
}
@@ -0,0 +1,46 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Privacy Subsystem implementation for availability_group.
*
* @package availability_group
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace availability_group\privacy;
defined('MOODLE_INTERNAL') || die();
/**
* Privacy Subsystem for availability_group implementing null_provider.
*
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider implements \core_privacy\local\metadata\null_provider {
/**
* Get the language string identifier with the component's language
* file to explain why this plugin stores no data.
*
* @return string
*/
public static function get_reason(): string {
return 'privacy:metadata';
}
}
@@ -0,0 +1,35 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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 <strong>{$a}</strong>';
$string['requires_notanygroup'] = 'You do not belong to any group';
$string['requires_notgroup'] = 'You do not belong to <strong>{$a}</strong>';
$string['title'] = 'Group';
$string['privacy:metadata'] = 'The Restriction by group plugin does not store any personal data.';
@@ -0,0 +1,156 @@
@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 | numsections |
| Course 1 | C1 | topics | 1 | 3 |
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 "activities" exist:
| activity | course | name |
| page | C1 | P1 |
| page | C1 | P2 |
| page | C1 | P3 |
@javascript
Scenario: Test condition
# Basic setup.
Given I am on the "P1" "page activity editing" page logged in as "teacher1"
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 the "P1" "page activity editing" page
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" in the "Add restriction..." "dialogue"
And I set the field "Group" to "(Any group)"
And I click on ".availability-item .availability-eye img" "css_element"
And I click on "Save and return to course" "button"
# Page P2 with group G1.
And I am on the "P2" "page activity editing" page
And I expand all fieldsets
And I click on "Add restriction..." "button"
And I click on "Group" "button" in the "Add restriction..." "dialogue"
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 am on the "P3" "page activity editing" page
And I expand all fieldsets
And I click on "Add restriction..." "button"
And I click on "Group" "button" in the "Add restriction..." "dialogue"
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 am on the "Course 1" "course" page logged in as "student1"
# 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 am on "Course 1" course homepage
# 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"
@javascript
Scenario: Condition display with filters
# Teacher sets up a restriction on group G1, using multilang filter.
Given the following "groups" exist:
| name | course | idnumber |
| <span lang="en" class="multilang">G-One</span><span lang="fr" class="multilang">G-Un</span> | C1 | GI1 |
And the "multilang" filter is "on"
And the "multilang" filter applies to "content and headings"
# The activity names filter is enabled because it triggered a bug in older versions.
And the "activitynames" filter is "on"
And the "activitynames" filter applies to "content and headings"
And I am on the "P1" "page activity editing" page logged in as "teacher1"
And I expand all fieldsets
And I click on "Add restriction..." "button"
And I click on "Group" "button" in the "Add restriction..." "dialogue"
And I set the field "Group" to "G-One"
And I click on "Save and return to course" "button"
# Student sees information about no access to group, with group name in correct language.
When I am on the "C1" "Course" page logged in as "student1"
Then I should see "Not available unless: You belong to G-One"
And I should not see "G-Un"
@javascript
Scenario: Condition using a hidden group
Given the following "groups" exist:
| name | course | idnumber | visibility |
| Hidden Group | C1 | GA | 3 |
And I log in as "teacher1"
And I add a page activity to course "Course 1" section "1"
And I expand all fieldsets
# Page P1 any group.
And I am on the "P1" "page activity editing" page
And I expand all fieldsets
And I click on "Add restriction..." "button"
And "Group" "button" should exist in the "Add restriction..." "dialogue"
And I click on "Group" "button" in the "Add restriction..." "dialogue"
And I set the field "Group" to "(Any group)"
And I click on ".availability-item .availability-eye img" "css_element"
And I click on "Save and return to course" "button"
# Page P2 with hidden group.
And I am on the "P2" "page activity editing" page
And I expand all fieldsets
And I click on "Add restriction..." "button"
And I click on "Group" "button" in the "Add restriction..." "dialogue"
And I set the field "Group" to "Hidden Group"
And I click on "Save and return to course" "button"
# Log back in as student.
When I am on the "Course 1" "course" page logged in as "student1"
# 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 "Hidden Group"
# Add to groups and log out/in again.
And the following "group members" exist:
| user | group |
| student1 | GA |
And I am on "Course 1" course homepage
# P1 (any groups) and P2 should show. The user should not see the hidden group mentioned anywhere.
And I should see "P1" in the "region-main" "region"
And I should see "P2" in the "region-main" "region"
And I should not see "Hidden Group"
@@ -0,0 +1,253 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace availability_group;
/**
* 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 condition_test extends \advanced_testcase {
/**
* Load required classes.
*/
public function setUp(): void {
// 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(): void {
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_and_enrol($course);
$usertwo = $generator->create_and_enrol($course);
$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);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~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);
$info = new \core_availability\mock_info($course, $user->id);
// Recheck.
$this->assertTrue($cond->is_available(false, $info, true, $user->id));
$this->assertFalse($cond->is_available(true, $info, true, $user->id));
$this->assertFalse($cond->is_available(false, $info, true, $usertwo->id));
$this->assertTrue($cond->is_available(true, $info, true, $usertwo->id));
$information = $cond->get_description(false, true, $info);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~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));
$this->assertFalse($cond->is_available(false, $info, true, $usertwo->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));
$this->assertFalse($cond->is_available(false, $info, true, $usertwo->id));
$this->assertTrue($cond->is_available(true, $info, true, $usertwo->id));
$information = $cond->get_description(false, true, $info);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~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);
$information = \core_availability\info::format_info($information, $course);
$this->assertMatchesRegularExpression('~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(): void {
// Invalid id (not int).
$structure = (object)array('id' => 'bourne');
try {
$cond = new condition($structure);
$this->fail();
} catch (\coding_exception $e) {
$this->assertStringContainsString('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(): void {
$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(): void {
$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. Also tests the SQL
* variant get_user_list_sql.
*/
public function test_filter_users(): void {
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['editingteacher']);
$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);
$expected = array($teacher->id, $students[1]->id, $students[2]->id);
$this->assertEquals($expected, $result);
// Test it with get_user_list_sql.
list ($sql, $params) = $cond->get_user_list_sql(false, $info, true);
$result = $DB->get_fieldset_sql($sql, $params);
sort($result);
$this->assertEquals($expected, $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);
$expected = array($teacher->id, $students[0]->id);
$this->assertEquals($expected, $result);
// Test with get_user_list_sql.
list ($sql, $params) = $cond->get_user_list_sql(true, $info, true);
$result = $DB->get_fieldset_sql($sql, $params);
sort($result);
$this->assertEquals($expected, $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);
$expected = array($teacher->id, $students[1]->id);
$this->assertEquals($expected, $result);
list ($sql, $params) = $cond->get_user_list_sql(false, $info, true);
$result = $DB->get_fieldset_sql($sql, $params);
sort($result);
$this->assertEquals($expected, $result);
$result = array_keys($cond->filter_user_list($allusers, true, $info, $checker));
ksort($result);
$expected = array($teacher->id, $students[0]->id, $students[2]->id);
$this->assertEquals($expected, $result);
list ($sql, $params) = $cond->get_user_list_sql(true, $info, true);
$result = $DB->get_fieldset_sql($sql, $params);
sort($result);
$this->assertEquals($expected, $result);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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 = 2024042200;
$plugin->requires = 2024041600;
$plugin->component = 'availability_group';
@@ -0,0 +1,117 @@
YUI.add('moodle-availability_group-form', function (Y, NAME) {
/**
* 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 html = '<label><span class="pr-3">' + M.util.get_string('title', 'availability_group') + '</span> ' +
'<span class="availability-group">' +
'<select name="id" class="custom-select">' +
'<option value="choose">' + M.util.get_string('choosedots', 'moodle') + '</option>' +
'<option value="any">' + M.util.get_string('anygroup', 'availability_group') + '</option>';
for (var i = 0; i < this.groups.length; i++) {
var group = this.groups[i];
// String has already been escaped using format_string.
html += '<option value="' + group.id + '" data-visibility="' + group.visibility + '">' + group.name + '</option>';
}
html += '</select></span></label>';
var node = Y.Node.create('<span class="d-flex flex-wrap align-items-center">' + html + '</span>');
var select = node.one('select[name=id]');
select.on('change', function(e) {
var value = e.target.get('value');
// Find the visibility of the selected group.
var visibility = e.target.one('option[value=' + value + ']').get('dataset').visibility;
var event;
if (visibility > 0) {
event = 'availability:privateRuleSet';
} else {
event = 'availability:privateRuleUnset';
}
node.fire(event, {plugin: 'group'});
});
// Set initial values (leave default 'choose' if creating afresh).
if (json.creating === undefined) {
if (json.id !== undefined) {
var option = select.one('option[value=' + json.id + ']');
if (option) {
select.set('value', '' + json.id);
var visibility = option.get('dataset').visibility;
if (visibility > 0) {
// Defer firing the event, to allow event bubbling to be set up in M.core_availability.form.
window.setTimeout(function() {
node.fire('availability:privateRuleSet', {plugin: 'group'});
}, 0);
}
}
} 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('.availability-field');
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');
}
};
}, '@VERSION@', {"requires": ["base", "node", "event", "moodle-core_availability-form"]});
@@ -0,0 +1 @@
YUI.add("moodle-availability_group-form",function(r,i){M.availability_group=M.availability_group||{},M.availability_group.form=r.Object(M.core_availability.plugin),M.availability_group.form.groups=null,M.availability_group.form.initInner=function(i){this.groups=i},M.availability_group.form.getNode=function(i){for(var a,e,t,l,o='<label><span class="pr-3">'+M.util.get_string("title","availability_group")+'</span> <span class="availability-group"><select name="id" class="custom-select"><option value="choose">'+M.util.get_string("choosedots","moodle")+'</option><option value="any">'+M.util.get_string("anygroup","availability_group")+"</option>",n=0;n<this.groups.length;n++)o+='<option value="'+(a=this.groups[n]).id+'" data-visibility="'+a.visibility+'">'+a.name+"</option>";return(t=(e=r.Node.create('<span class="d-flex flex-wrap align-items-center">'+(o+="</select></span></label>")+"</span>")).one("select[name=id]")).on("change",function(i){var a=i.target.get("value"),a=i.target.one("option[value="+a+"]").get("dataset").visibility,i=0<a?"availability:privateRuleSet":"availability:privateRuleUnset";e.fire(i,{plugin:"group"})}),i.creating===undefined&&(i.id!==undefined?(l=t.one("option[value="+i.id+"]"))&&(t.set("value",""+i.id),0<l.get("dataset").visibility&&window.setTimeout(function(){e.fire("availability:privateRuleSet",{plugin:"group"})},0)):i.id===undefined&&e.one("select[name=id]").set("value","any")),M.availability_group.form.addedEvents||(M.availability_group.form.addedEvents=!0,r.one(".availability-field").delegate("change",function(){M.core_availability.form.update()},".availability_group select")),e},M.availability_group.form.fillValue=function(i,a){a=a.one("select[name=id]").get("value");"choose"===a?i.id="choose":"any"!==a&&(i.id=parseInt(a,10))},M.availability_group.form.fillErrors=function(i,a){var e={};this.fillValue(e,a),e.id&&"choose"===e.id&&i.push("availability_group:error_selectgroup")}},"@VERSION@",{requires:["base","node","event","moodle-core_availability-form"]});
@@ -0,0 +1,117 @@
YUI.add('moodle-availability_group-form', function (Y, NAME) {
/**
* 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 html = '<label><span class="pr-3">' + M.util.get_string('title', 'availability_group') + '</span> ' +
'<span class="availability-group">' +
'<select name="id" class="custom-select">' +
'<option value="choose">' + M.util.get_string('choosedots', 'moodle') + '</option>' +
'<option value="any">' + M.util.get_string('anygroup', 'availability_group') + '</option>';
for (var i = 0; i < this.groups.length; i++) {
var group = this.groups[i];
// String has already been escaped using format_string.
html += '<option value="' + group.id + '" data-visibility="' + group.visibility + '">' + group.name + '</option>';
}
html += '</select></span></label>';
var node = Y.Node.create('<span class="d-flex flex-wrap align-items-center">' + html + '</span>');
var select = node.one('select[name=id]');
select.on('change', function(e) {
var value = e.target.get('value');
// Find the visibility of the selected group.
var visibility = e.target.one('option[value=' + value + ']').get('dataset').visibility;
var event;
if (visibility > 0) {
event = 'availability:privateRuleSet';
} else {
event = 'availability:privateRuleUnset';
}
node.fire(event, {plugin: 'group'});
});
// Set initial values (leave default 'choose' if creating afresh).
if (json.creating === undefined) {
if (json.id !== undefined) {
var option = select.one('option[value=' + json.id + ']');
if (option) {
select.set('value', '' + json.id);
var visibility = option.get('dataset').visibility;
if (visibility > 0) {
// Defer firing the event, to allow event bubbling to be set up in M.core_availability.form.
window.setTimeout(function() {
node.fire('availability:privateRuleSet', {plugin: 'group'});
}, 0);
}
}
} 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('.availability-field');
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');
}
};
}, '@VERSION@', {"requires": ["base", "node", "event", "moodle-core_availability-form"]});
@@ -0,0 +1,10 @@
{
"name": "moodle-availability_group-form",
"builds": {
"moodle-availability_group-form": {
"jsfiles": [
"form.js"
]
}
}
}
+112
View File
@@ -0,0 +1,112 @@
/**
* 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 html = '<label><span class="pr-3">' + M.util.get_string('title', 'availability_group') + '</span> ' +
'<span class="availability-group">' +
'<select name="id" class="custom-select">' +
'<option value="choose">' + M.util.get_string('choosedots', 'moodle') + '</option>' +
'<option value="any">' + M.util.get_string('anygroup', 'availability_group') + '</option>';
for (var i = 0; i < this.groups.length; i++) {
var group = this.groups[i];
// String has already been escaped using format_string.
html += '<option value="' + group.id + '" data-visibility="' + group.visibility + '">' + group.name + '</option>';
}
html += '</select></span></label>';
var node = Y.Node.create('<span class="d-flex flex-wrap align-items-center">' + html + '</span>');
var select = node.one('select[name=id]');
select.on('change', function(e) {
var value = e.target.get('value');
// Find the visibility of the selected group.
var visibility = e.target.one('option[value=' + value + ']').get('dataset').visibility;
var event;
if (visibility > 0) {
event = 'availability:privateRuleSet';
} else {
event = 'availability:privateRuleUnset';
}
node.fire(event, {plugin: 'group'});
});
// Set initial values (leave default 'choose' if creating afresh).
if (json.creating === undefined) {
if (json.id !== undefined) {
var option = select.one('option[value=' + json.id + ']');
if (option) {
select.set('value', '' + json.id);
var visibility = option.get('dataset').visibility;
if (visibility > 0) {
// Defer firing the event, to allow event bubbling to be set up in M.core_availability.form.
window.setTimeout(function() {
node.fire('availability:privateRuleSet', {plugin: 'group'});
}, 0);
}
}
} 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('.availability-field');
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');
}
};
@@ -0,0 +1,10 @@
{
"moodle-availability_group-form": {
"requires": [
"base",
"node",
"event",
"moodle-core_availability-form"
]
}
}
@@ -0,0 +1,310 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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 {
// Not safe to call format_string here; use the special function to call it later.
$name = self::description_format_string(self::$groupingnames[$groupingid]);
}
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;
}
}
/**
* Include this condition only if we are including groups in restore, or
* if it's a generic 'same activity' one.
*
* @param int $restoreid The restore Id.
* @param int $courseid The ID of the course.
* @param base_logger $logger The logger being used.
* @param string $name Name of item being restored.
* @param base_task $task The task being performed.
*
* @return Integer groupid
*/
public function include_after_restore($restoreid, $courseid, \base_logger $logger,
$name, \base_task $task) {
return !$this->groupingid || $task->get_setting_value('groups');
}
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;
// If the array is empty already, just return it.
if (!$users) {
return $users;
}
// 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;
}
/**
* Returns a JSON object which corresponds to a condition of this type.
*
* Intended for unit testing, as normally the JSON values are constructed
* by JavaScript code.
*
* @param int $groupingid Required grouping id (0 = grouping linked to activity)
* @return stdClass Object representing condition
*/
public static function get_json($groupingid = 0) {
$result = (object)array('type' => 'grouping');
if ($groupingid) {
$result->id = (int)$groupingid;
} else {
$result->activity = true;
}
return $result;
}
public function get_user_list_sql($not, \core_availability\info $info, $onlyactive) {
global $DB;
// Get enrolled users with access all groups. These always are allowed.
list($aagsql, $aagparams) = get_enrolled_sql(
$info->get_context(), 'moodle/site:accessallgroups', 0, $onlyactive);
// Get all enrolled users.
list ($enrolsql, $enrolparams) =
get_enrolled_sql($info->get_context(), '', 0, $onlyactive);
// Condition for specified or any group.
$matchparams = array();
$matchsql = "SELECT 1
FROM {groups_members} gm
JOIN {groupings_groups} gg ON gg.groupid = gm.groupid
WHERE gm.userid = userids.id
AND gg.groupingid = " .
self::unique_sql_parameter($matchparams, $this->get_grouping_id($info));
// Overall query combines all this.
$condition = $not ? 'NOT' : '';
$sql = "SELECT userids.id
FROM ($enrolsql) userids
WHERE (userids.id IN ($aagsql)) OR $condition EXISTS ($matchsql)";
return array($sql, array_merge($enrolparams, $aagparams, $matchparams));
}
}
@@ -0,0 +1,83 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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',
['courseid' => $courseid], 'name');
$this->allgroupingscourseid = $courseid;
}
return $this->allgroupings;
}
protected function allow_add($course, \cm_info $cm = null,
\section_info $section = null) {
global $CFG, $DB;
// 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;
}
}
@@ -0,0 +1,46 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Privacy Subsystem implementation for availability_grouping.
*
* @package availability_grouping
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace availability_grouping\privacy;
defined('MOODLE_INTERNAL') || die();
/**
* Privacy Subsystem for availability_grouping implementing null_provider.
*
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider implements \core_privacy\local\metadata\null_provider {
/**
* Get the language string identifier with the component's language
* file to explain why this plugin stores no data.
*
* @return string
*/
public static function get_reason(): string {
return 'privacy:metadata';
}
}
@@ -0,0 +1,32 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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 <strong>{$a}</strong>';
$string['requires_notgrouping'] = 'You do not belong to a group in <strong>{$a}</strong>';
$string['title'] = 'Grouping';
$string['privacy:metadata'] = 'The Restriction by grouping plugin does not store any personal data.';
@@ -0,0 +1,129 @@
@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 |
# Basic setup.
And the following "activities" exist:
| activity | course | name |
| page | C1 | P1 |
| page | C1 | P2 |
@javascript
Scenario: Test condition
# Start to add a Page. If there aren't any groupings, there's no Grouping option.
Given I am on the "P1" "page activity editing" page logged in as "teacher1"
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 "Course 1" course homepage
And the following "groupings" exist:
| name | course | idnumber |
| GX1 | C1 | GXI1 |
| GX2 | C1 | GXI2 |
And I am on the "P1" "page activity editing" page
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 click on "Save and return to course" "button"
# Page P2 with grouping GX2.
And I am on the "P2" "page activity editing" page
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 am on the "Course 1" "course" page logged in as "student1"
# 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 the following "grouping groups" exist:
| grouping | group |
| GXI1 | GI1 |
And I am on the "Course 1" "course" page logged in as "student1"
# 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"
@javascript
Scenario: Check grouping access restriction message on course homepage
Given the following "groupings" exist:
| name | course | idnumber |
| Grouping A | C1 | GA |
And the following "grouping groups" exist:
| grouping | group |
| GA | GI1 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | grouping |
| assign | Test assign | Assign description | C1 | assign1 | 1 | GA |
And I log in as "teacher1"
And I am on "Course 1" course homepage
And I turn editing mode on
And I open "Test assign" actions menu
And I choose "Edit settings" in the open action menu
And I expand all fieldsets
And the field "groupingid" matches value "Grouping A"
And I press "Add group/grouping access restriction"
When I press "Save and return to course"
Then I should see "Not available unless: You belong to a group in Grouping A"
@javascript
Scenario: Condition display with filters
# Teacher sets up a restriction on group G1, using multilang filter.
Given the following "groupings" exist:
| name | course | idnumber |
| <span lang="en" class="multilang">Gr-One</span><span lang="fr" class="multilang">Gr-Un</span> | C1 | GA |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | grouping |
| assign | Test assign | Assign description | C1 | assign1 | 1 | GA |
And the "multilang" filter is "on"
And the "multilang" filter applies to "content and headings"
# The activity names filter is enabled because it triggered a bug in older versions.
And the "activitynames" filter is "on"
And the "activitynames" filter applies to "content and headings"
And I am on the "Test assign" "assign activity editing" page logged in as "teacher1"
And I expand all fieldsets
And I press "Add group/grouping access restriction"
And I press "Save and return to course"
# Student sees information about no access to group, with group name in correct language.
When I am on the "C1" "Course" page logged in as "student1"
Then I should see "Not available unless: You belong to a group in Gr-One"
And I should not see "Gr-Un"
@@ -0,0 +1,287 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace availability_grouping;
/**
* 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 condition_test extends \advanced_testcase {
/**
* Load required classes.
*/
public function setUp(): void {
// 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(): void {
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->assertMatchesRegularExpression('~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->assertMatchesRegularExpression('~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->assertMatchesRegularExpression('~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->assertMatchesRegularExpression('~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(): void {
// No parameters.
$structure = new \stdClass();
try {
$cond = new condition($structure);
$this->fail();
} catch (\coding_exception $e) {
$this->assertStringContainsString('Missing ->id / ->activity', $e->getMessage());
}
// Invalid id (not int).
$structure->id = 'bourne';
try {
$cond = new condition($structure);
$this->fail();
} catch (\coding_exception $e) {
$this->assertStringContainsString('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->assertStringContainsString('Invalid ->activity', $e->getMessage());
}
// Invalid activity option (false).
$structure->activity = false;
try {
$cond = new condition($structure);
$this->fail();
} catch (\coding_exception $e) {
$this->assertStringContainsString('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(): void {
$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(): void {
$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. Also tests the SQL
* variant get_user_list_sql.
*/
public function test_filter_users(): void {
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['editingteacher']);
$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);
$expected = array($teacher->id, $students[1]->id);
$this->assertEquals($expected, $result);
// Test it with get_user_list_sql.
list ($sql, $params) = $cond->get_user_list_sql(false, $info, true);
$result = $DB->get_fieldset_sql($sql, $params);
sort($result);
$this->assertEquals($expected, $result);
// NOT test.
$result = array_keys($cond->filter_user_list($allusers, true, $info, $checker));
ksort($result);
$expected = array($teacher->id, $students[0]->id, $students[2]->id);
$this->assertEquals($expected, $result);
// NOT with get_user_list_sql.
list ($sql, $params) = $cond->get_user_list_sql(true, $info, true);
$result = $DB->get_fieldset_sql($sql, $params);
sort($result);
$this->assertEquals($expected, $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));
$expected = array($teacher->id, $students[2]->id);
$this->assertEquals($expected, $result);
// With get_user_list_sql.
list ($sql, $params) = $info->get_user_list_sql(true);
$result = $DB->get_fieldset_sql($sql, $params);
sort($result);
$this->assertEquals($expected, $result);
}
}
@@ -0,0 +1,29 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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 = 2024042200;
$plugin->requires = 2024041600;
$plugin->component = 'availability_grouping';
@@ -0,0 +1,87 @@
YUI.add('moodle-availability_grouping-form', function (Y, NAME) {
/**
* 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 html = '<label><span class="pr-3">' + M.util.get_string('title', 'availability_grouping') + '</span> ' +
'<span class="availability-group">' +
'<select name="id" class="custom-select">' +
'<option value="choose">' + M.util.get_string('choosedots', 'moodle') + '</option>';
for (var i = 0; i < this.groupings.length; i++) {
var grouping = this.groupings[i];
// String has already been escaped using format_string.
html += '<option value="' + grouping.id + '">' + grouping.name + '</option>';
}
html += '</select></span></label>';
var node = Y.Node.create('<span class="d-flex flex-wrap align-items-center">' + html + '</span>');
// 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('.availability-field');
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');
}
};
}, '@VERSION@', {"requires": ["base", "node", "event", "moodle-core_availability-form"]});
@@ -0,0 +1 @@
YUI.add("moodle-availability_grouping-form",function(n,i){M.availability_grouping=M.availability_grouping||{},M.availability_grouping.form=n.Object(M.core_availability.plugin),M.availability_grouping.form.groupings=null,M.availability_grouping.form.initInner=function(i){this.groupings=i},M.availability_grouping.form.getNode=function(i){for(var a,e,l='<label><span class="pr-3">'+M.util.get_string("title","availability_grouping")+'</span> <span class="availability-group"><select name="id" class="custom-select"><option value="choose">'+M.util.get_string("choosedots","moodle")+"</option>",o=0;o<this.groupings.length;o++)l+='<option value="'+(a=this.groupings[o]).id+'">'+a.name+"</option>";return e=n.Node.create('<span class="d-flex flex-wrap align-items-center">'+(l+="</select></span></label>")+"</span>"),i.id!==undefined&&e.one("select[name=id] > option[value="+i.id+"]")&&e.one("select[name=id]").set("value",""+i.id),M.availability_grouping.form.addedEvents||(M.availability_grouping.form.addedEvents=!0,n.one(".availability-field").delegate("change",function(){M.core_availability.form.update()},".availability_grouping select")),e},M.availability_grouping.form.fillValue=function(i,a){a=a.one("select[name=id]").get("value");i.id="choose"===a?"choose":parseInt(a,10)},M.availability_grouping.form.fillErrors=function(i,a){var e={};this.fillValue(e,a),"choose"===e.id&&i.push("availability_grouping:error_selectgrouping")}},"@VERSION@",{requires:["base","node","event","moodle-core_availability-form"]});
@@ -0,0 +1,87 @@
YUI.add('moodle-availability_grouping-form', function (Y, NAME) {
/**
* 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 html = '<label><span class="pr-3">' + M.util.get_string('title', 'availability_grouping') + '</span> ' +
'<span class="availability-group">' +
'<select name="id" class="custom-select">' +
'<option value="choose">' + M.util.get_string('choosedots', 'moodle') + '</option>';
for (var i = 0; i < this.groupings.length; i++) {
var grouping = this.groupings[i];
// String has already been escaped using format_string.
html += '<option value="' + grouping.id + '">' + grouping.name + '</option>';
}
html += '</select></span></label>';
var node = Y.Node.create('<span class="d-flex flex-wrap align-items-center">' + html + '</span>');
// 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('.availability-field');
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');
}
};
}, '@VERSION@', {"requires": ["base", "node", "event", "moodle-core_availability-form"]});
@@ -0,0 +1,10 @@
{
"name": "moodle-availability_grouping-form",
"builds": {
"moodle-availability_grouping-form": {
"jsfiles": [
"form.js"
]
}
}
}
+82
View File
@@ -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 html = '<label><span class="pr-3">' + M.util.get_string('title', 'availability_grouping') + '</span> ' +
'<span class="availability-group">' +
'<select name="id" class="custom-select">' +
'<option value="choose">' + M.util.get_string('choosedots', 'moodle') + '</option>';
for (var i = 0; i < this.groupings.length; i++) {
var grouping = this.groupings[i];
// String has already been escaped using format_string.
html += '<option value="' + grouping.id + '">' + grouping.name + '</option>';
}
html += '</select></span></label>';
var node = Y.Node.create('<span class="d-flex flex-wrap align-items-center">' + html + '</span>');
// 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('.availability-field');
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');
}
};
@@ -0,0 +1,10 @@
{
"moodle-availability_grouping-form": {
"requires": [
"base",
"node",
"event",
"moodle-core_availability-form"
]
}
}
@@ -0,0 +1,654 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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;
}
/**
* Returns a JSON object which corresponds to a condition of this type.
*
* Intended for unit testing, as normally the JSON values are constructed
* by JavaScript code.
*
* @param bool $customfield True if this is a custom field
* @param string $fieldname Field name
* @param string $operator Operator name (OP_xx constant)
* @param string|null $value Value (not required for some operator types)
* @return stdClass Object representing condition
*/
public static function get_json($customfield, $fieldname, $operator, $value = null) {
$result = (object)array('type' => 'profile', 'op' => $operator);
if ($customfield) {
$result->cf = $fieldname;
} else {
$result->sf = $fieldname;
}
switch ($operator) {
case self::OP_IS_EMPTY:
case self::OP_IS_NOT_EMPTY:
break;
default:
if (is_null($value)) {
throw new \coding_exception('Operator requires value');
}
$result->v = $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 {
$standardfields = self::get_standard_profile_fields();
if (array_key_exists($this->standardfield, $standardfields)) {
$translatedfieldname = $standardfields[$this->standardfield];
} else {
$translatedfieldname = get_string('missing', 'availability_profile', $this->standardfield);
}
}
$a = new \stdClass();
// Not safe to call format_string here; use the special function to call it later.
$a->field = self::description_format_string($translatedfieldname);
$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;
}
/**
* Return list of standard user profile fields used by the condition
*
* @return string[]
*/
public static function get_standard_profile_fields(): array {
return [
'firstname' => \core_user\fields::get_display_name('firstname'),
'lastname' => \core_user\fields::get_display_name('lastname'),
'email' => \core_user\fields::get_display_name('email'),
'city' => \core_user\fields::get_display_name('city'),
'country' => \core_user\fields::get_display_name('country'),
'idnumber' => \core_user\fields::get_display_name('idnumber'),
'institution' => \core_user\fields::get_display_name('institution'),
'department' => \core_user\fields::get_display_name('department'),
'phone1' => \core_user\fields::get_display_name('phone1'),
'phone2' => \core_user\fields::get_display_name('phone2'),
'address' => \core_user\fields::get_display_name('address'),
];
}
/**
* Gets data about custom profile fields. Cached statically in current
* request.
*
* This only includes fields which can be tested by the system (those whose
* data is cached in $USER object) - basically doesn't include textarea type
* fields.
*
* @return array Array of records indexed by shortname
*/
public static function get_custom_profile_fields() {
global $DB, $CFG;
if (self::$customprofilefields === null) {
// Get fields and store them indexed by shortname.
require_once($CFG->dirroot . '/user/profile/lib.php');
$fields = profile_get_custom_fields(true);
self::$customprofilefields = array();
foreach ($fields as $field) {
self::$customprofilefields[$field->shortname] = $field;
}
}
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;
// If the array is empty already, just return it.
if (!$users) {
return $users;
}
// 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 {
$standardfields = self::get_standard_profile_fields();
if (!array_key_exists($this->standardfield, $standardfields)) {
// If the field isn't found, nobody matches.
return [];
}
$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;
}
/**
* Gets SQL to match a field against this condition. The second copy of the
* field is in case you're using variables for the field so that it needs
* to be two different ones.
*
* @param string $field Field name
* @param string $field2 Second copy of field name (default same).
* @param boolean $istext Any of the fields correspond to a TEXT column in database (true) or not (false).
* @return array Array of SQL and parameters
*/
private function get_condition_sql($field, $field2 = null, $istext = false) {
global $DB;
if (is_null($field2)) {
$field2 = $field;
}
$params = array();
switch($this->operator) {
case self::OP_CONTAINS:
$sql = $DB->sql_like($field, self::unique_sql_parameter(
$params, '%' . $this->value . '%'));
break;
case self::OP_DOES_NOT_CONTAIN:
if (empty($this->value)) {
// The 'does not contain nothing' expression matches everyone.
return null;
}
$sql = $DB->sql_like($field, self::unique_sql_parameter(
$params, '%' . $this->value . '%'), true, true, true);
break;
case self::OP_IS_EQUAL_TO:
if ($istext) {
$sql = $DB->sql_compare_text($field) . ' = ' . $DB->sql_compare_text(
self::unique_sql_parameter($params, $this->value));
} else {
$sql = $field . ' = ' . self::unique_sql_parameter(
$params, $this->value);
}
break;
case self::OP_STARTS_WITH:
$sql = $DB->sql_like($field, self::unique_sql_parameter(
$params, $this->value . '%'));
break;
case self::OP_ENDS_WITH:
$sql = $DB->sql_like($field, self::unique_sql_parameter(
$params, '%' . $this->value));
break;
case self::OP_IS_EMPTY:
// Mimic PHP empty() behaviour for strings, '0' or ''.
$emptystring = self::unique_sql_parameter($params, '');
if ($istext) {
$sql = '(' . $DB->sql_compare_text($field) . " IN ('0', $emptystring) OR $field2 IS NULL)";
} else {
$sql = '(' . $field . " IN ('0', $emptystring) OR $field2 IS NULL)";
}
break;
case self::OP_IS_NOT_EMPTY:
$emptystring = self::unique_sql_parameter($params, '');
if ($istext) {
$sql = '(' . $DB->sql_compare_text($field) . " NOT IN ('0', $emptystring) AND $field2 IS NOT NULL)";
} else {
$sql = '(' . $field . " NOT IN ('0', $emptystring) AND $field2 IS NOT NULL)";
}
break;
}
return array($sql, $params);
}
public function get_user_list_sql($not, \core_availability\info $info, $onlyactive) {
global $DB;
// Build suitable SQL depending on custom or standard field.
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('SELECT id FROM {user} WHERE 0 = 1', array());
}
$customfield = $customfields[$this->customfield];
$mainparams = array();
$tablesql = "LEFT JOIN {user_info_data} ud ON ud.fieldid = " .
self::unique_sql_parameter($mainparams, $customfield->id) .
" AND ud.userid = userids.id";
list ($condition, $conditionparams) = $this->get_condition_sql('ud.data', null, true);
$mainparams = array_merge($mainparams, $conditionparams);
// If default is true, then allow that too.
if ($this->is_field_condition_met(
$this->operator, $customfield->defaultdata, $this->value)) {
$where = "((ud.data IS NOT NULL AND $condition) OR (ud.data IS NULL))";
} else {
$where = "(ud.data IS NOT NULL AND $condition)";
}
} else {
$standardfields = self::get_standard_profile_fields();
if (!array_key_exists($this->standardfield, $standardfields)) {
// If the field isn't found, nobody matches.
return ['SELECT id FROM {user} WHERE 0 = 1', []];
}
$tablesql = "JOIN {user} u ON u.id = userids.id";
list ($where, $mainparams) = $this->get_condition_sql(
'u.' . $this->standardfield);
}
// Handle NOT.
if ($not) {
$where = 'NOT (' . $where . ')';
}
// Get enrolled user SQL and combine with this query.
list ($enrolsql, $enrolparams) =
get_enrolled_sql($info->get_context(), '', 0, $onlyactive);
$sql = "SELECT userids.id
FROM ($enrolsql) userids
$tablesql
WHERE $where";
$params = array_merge($enrolparams, $mainparams);
return array($sql, $params);
}
}
@@ -0,0 +1,62 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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 = condition::get_standard_profile_fields();
\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'));
}
}
@@ -0,0 +1,46 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Privacy Subsystem implementation for availability_profile.
*
* @package availability_profile
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace availability_profile\privacy;
defined('MOODLE_INTERNAL') || die();
/**
* Privacy Subsystem for availability_profile implementing null_provider.
*
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider implements \core_privacy\local\metadata\null_provider {
/**
* Get the language string identifier with the component's language
* file to explain why this plugin stores no data.
*
* @return string
*/
public static function get_reason(): string {
return 'privacy:metadata';
}
}
@@ -0,0 +1,51 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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 <strong>{$a->field}</strong> contains <strong>{$a->value}</strong>';
$string['requires_doesnotcontain'] = 'Your <strong>{$a->field}</strong> does not contain <strong>{$a->value}</strong>';
$string['requires_endswith'] = 'Your <strong>{$a->field}</strong> ends with <strong>{$a->value}</strong>';
$string['requires_isempty'] = 'Your <strong>{$a->field}</strong> is empty';
$string['requires_isequalto'] = 'Your <strong>{$a->field}</strong> is <strong>{$a->value}</strong>';
$string['requires_isnotempty'] = 'Your <strong>{$a->field}</strong> is not empty';
$string['requires_notendswith'] = 'Your <strong>{$a->field}</strong> does not end with <strong>{$a->value}</strong>';
$string['requires_notisequalto'] = 'Your <strong>{$a->field}</strong> is not <strong>{$a->value}</strong>';
$string['requires_notstartswith'] = 'Your <strong>{$a->field}</strong> does not start with <strong>{$a->value}</strong>';
$string['requires_startswith'] = 'Your <strong>{$a->field}</strong> starts with <strong>{$a->value}</strong>';
$string['missing'] = '(Missing 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';
$string['privacy:metadata'] = 'The Restriction by profile plugin does not store any personal data.';
@@ -0,0 +1,109 @@
@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.com |
| student1 | s@example.com |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
And the following "activities" exist:
| activity | course | name |
| page | C1 | P1 |
| page | C1 | P2 |
@javascript
Scenario: Test condition
# Basic setup.
Given I am on the "P1" "page activity editing" page logged in as "teacher1"
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.com"
And I click on ".availability-item .availability-eye img" "css_element"
And I click on "Save and return to course" "button"
# Add
And I am on the "P2" "page activity editing" page
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.com"
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 am on the "Course 1" "course" page logged in as "student1"
# 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"
@javascript
Scenario: Test with custom user profile field
Given the following "custom profile fields" exist:
| datatype | shortname | name |
| text | superfield | Super field |
# Set field value for user.
And I am on the "s@example.com" "user > editing" page logged in as "admin"
And I expand all fieldsets
And I set the field "Super field" to "Bananaman"
And I click on "Update profile" "button"
# Set Page activity which has requirement on this field.
And I am on the "P1" "page activity editing" page
And I expand all fieldsets
And I click on "Add restriction..." "button"
And I click on "User profile" "button"
And I set the following fields to these values:
| User profile field | Super field |
| Value to compare against | Bananaman |
And I click on ".availability-item .availability-eye img" "css_element"
And I click on "Save and return to course" "button"
# Edit it again and check the setting still works.
When I am on the P1 "page activity editing" page
And I expand all fieldsets
Then the field "User profile field" matches value "Super field"
And the field "Value to compare against" matches value "Bananaman"
# Log out and back in as student. Should be able to see activity.
And I am on the "Course 1" "course" page logged in as "student1"
Then I should see "P1" in the "region-main" "region"
@javascript
Scenario: Condition display with filters
# Teacher sets up a restriction on group G1, using multilang filter.
Given the following "custom profile fields" exist:
| datatype | shortname | name | param2 |
| text | frog | <span lang="en" class="multilang">F-One</span><span lang="fr" class="multilang">F-Un</span> | 100 |
And the "multilang" filter is "on"
And the "multilang" filter applies to "content and headings"
# The activity names filter is enabled because it triggered a bug in older versions.
And the "activitynames" filter is "on"
And the "activitynames" filter applies to "content and headings"
And I am on the "P1" "page activity editing" page logged in as "teacher1"
And I expand all fieldsets
And I click on "Add restriction..." "button"
And I click on "User profile" "button" in the "Add restriction..." "dialogue"
And I set the following fields to these values:
| User profile field | F-One |
| Value to compare against | 111 |
And I click on "Save and return to course" "button"
And I log out
# Student sees information about no access to group, with group name in correct language.
When I am on the "C1" "Course" page logged in as "student1"
Then I should see "Not available unless: Your F-One is 111"
And I should not see "F-Un"
@@ -0,0 +1,536 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace availability_profile;
/**
* 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 condition_test 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();
/** @var condition Current condition */
private $cond;
/** @var \core_availability\info Current info */
private $info;
public function setUp(): void {
global $DB, $CFG;
$this->resetAfterTest();
// Add a custom profile field type.
$this->profilefield = $this->getDataGenerator()->create_custom_profile_field(array(
'shortname' => 'frogtype', 'name' => 'Type of frog',
'datatype' => 'text'));
// 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(): void {
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(): void {
// No parameters.
$structure = new \stdClass();
try {
$cond = new condition($structure);
$this->fail();
} catch (\coding_exception $e) {
$this->assertStringContainsString('Missing or invalid ->op', $e->getMessage());
}
// Invalid op.
$structure->op = 'isklingonfor';
try {
$cond = new condition($structure);
$this->fail();
} catch (\coding_exception $e) {
$this->assertStringContainsString('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->assertStringContainsString('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->assertStringContainsString('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->assertStringContainsString('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->assertStringContainsString('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->assertStringContainsString('Invalid ->sf', $e->getMessage());
}
// Both fields.
$structure->sf = 'department';
$structure->cf = 'frogtype';
try {
$cond = new condition($structure);
$this->fail();
} catch (\coding_exception $e) {
$this->assertStringContainsString('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->assertStringContainsString('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(): void {
$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(): void {
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);
$information = \core_availability\info::format_info($information, $info->get_course());
$this->assertMatchesRegularExpression('~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));
}
/**
* Tests what happens with custom fields that are text areas. These should
* not be offered in the menu because their data is not included in user
* object
*/
public function test_custom_textarea_field(): void {
global $USER, $SITE, $DB;
$this->setAdminUser();
$info = new \core_availability\mock_info();
// Add custom textarea type.
$customfield = $this->getDataGenerator()->create_custom_profile_field(array(
'shortname' => 'longtext', 'name' => 'Long text',
'datatype' => 'textarea'));
// The list of fields should include the text field added in setUp(),
// but should not include the textarea field added just now.
$fields = condition::get_custom_profile_fields();
$this->assertArrayHasKey('frogtype', $fields);
$this->assertArrayNotHasKey('longtext', $fields);
}
/**
* Sets the custom profile field used for testing.
*
* @param int $userid User id
* @param string|null $value Field value or null to clear
* @param int $fieldid Field id or 0 to use default one
*/
protected function set_field($userid, $value, $fieldid = 0) {
global $DB, $USER;
if (!$fieldid) {
$fieldid = $this->profilefield->id;
}
$alreadyset = array_key_exists($userid, $this->setusers);
if (is_null($value)) {
$DB->delete_records('user_info_data',
array('userid' => $userid, 'fieldid' => $fieldid));
unset($this->setusers[$userid]);
} else if ($alreadyset) {
$DB->set_field('user_info_data', 'data', $value,
array('userid' => $userid, 'fieldid' => $fieldid));
} else {
$DB->insert_record('user_info_data', array('userid' => $userid,
'fieldid' => $fieldid, '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);
$information = \core_availability\info::format_info($information, $info->get_course());
$this->assertMatchesRegularExpression($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);
$information = \core_availability\info::format_info($information, $info->get_course());
$this->assertMatchesRegularExpression($failpattern, $information);
}
}
/**
* Tests the filter_users (bulk checking) function.
*/
public function test_filter_users(): void {
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);
}
/**
* Tests getting user list SQL. This is a different test from the above because
* there is some additional code in this function so more variants need testing.
*/
public function test_get_user_list_sql(): void {
global $DB, $CFG;
$this->resetAfterTest();
$CFG->enableavailability = true;
// Erase static cache before test.
condition::wipe_static_cache();
// For testing, make another info field with default value.
$otherprofilefield = $this->getDataGenerator()->create_custom_profile_field(array(
'shortname' => 'tonguestyle', 'name' => 'Tongue style',
'datatype' => 'text', 'defaultdata' => 'Slimy'));
// 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'));
$student4 = $generator->create_user(array('institution' => '0'));
$allusers = array();
foreach (array($student1, $student2, $student3, $student4) 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');
$this->set_field($student3->id, 'Rough', $otherprofilefield->id);
$this->info = new \core_availability\mock_info($course);
// Test standard field condition (positive).
$this->cond = new condition((object)array('sf' => 'institution',
'op' => condition::OP_CONTAINS, 'v' => 'Univ'));
$this->assert_user_list_sql_results(array($student1->id, $student3->id));
// Now try it negative.
$this->assert_user_list_sql_results(array($student2->id, $student4->id), true);
// Try all the other condition types.
$this->cond = new condition((object)array('sf' => 'institution',
'op' => condition::OP_DOES_NOT_CONTAIN, 'v' => 's'));
$this->assert_user_list_sql_results(array($student4->id));
$this->cond = new condition((object)array('sf' => 'institution',
'op' => condition::OP_IS_EQUAL_TO, 'v' => 'Hogwarts'));
$this->assert_user_list_sql_results(array($student2->id));
$this->cond = new condition((object)array('sf' => 'institution',
'op' => condition::OP_STARTS_WITH, 'v' => 'U'));
$this->assert_user_list_sql_results(array($student1->id, $student3->id));
$this->cond = new condition((object)array('sf' => 'institution',
'op' => condition::OP_ENDS_WITH, 'v' => 'rts'));
$this->assert_user_list_sql_results(array($student2->id));
$this->cond = new condition((object)array('sf' => 'institution',
'op' => condition::OP_IS_EMPTY));
$this->assert_user_list_sql_results(array($student4->id));
$this->cond = new condition((object)array('sf' => 'institution',
'op' => condition::OP_IS_NOT_EMPTY));
$this->assert_user_list_sql_results(array($student1->id, $student2->id, $student3->id));
// Try with a custom field condition that doesn't have a default.
$this->cond = new condition((object)array('cf' => 'frogtype',
'op' => condition::OP_CONTAINS, 'v' => 'poison'));
$this->assert_user_list_sql_results(array($student1->id, $student2->id));
$this->cond = new condition((object)array('cf' => 'frogtype',
'op' => condition::OP_IS_EMPTY));
$this->assert_user_list_sql_results(array($student3->id, $student4->id));
// Try with one that does have a default.
$this->cond = new condition((object)array('cf' => 'tonguestyle',
'op' => condition::OP_STARTS_WITH, 'v' => 'Sli'));
$this->assert_user_list_sql_results(array($student1->id, $student2->id,
$student4->id));
$this->cond = new condition((object)array('cf' => 'tonguestyle',
'op' => condition::OP_IS_EMPTY));
$this->assert_user_list_sql_results(array());
}
/**
* Convenience function. Gets the user list SQL and runs it, then checks
* results.
*
* @param array $expected Array of expected user ids
* @param bool $not True if using NOT condition
*/
private function assert_user_list_sql_results(array $expected, $not = false) {
global $DB;
list ($sql, $params) = $this->cond->get_user_list_sql($not, $this->info, true);
$result = $DB->get_fieldset_sql($sql, $params);
sort($result);
$this->assertEquals($expected, $result);
}
}
@@ -0,0 +1,29 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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 = 2024042200;
$plugin->requires = 2024041600;
$plugin->component = 'availability_profile';
@@ -0,0 +1,139 @@
YUI.add('moodle-availability_profile-form', function (Y, NAME) {
/**
* 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 html = '<span class="availability-group"><label><span class="pr-3">' +
M.util.get_string('conditiontitle', 'availability_profile') + '</span> ' +
'<select name="field" class="custom-select">' +
'<option value="choose">' + M.util.get_string('choosedots', 'moodle') + '</option>';
var fieldInfo;
for (var i = 0; i < this.standardFields.length; i++) {
fieldInfo = this.standardFields[i];
// String has already been escaped using format_string.
html += '<option value="sf_' + fieldInfo.field + '">' + fieldInfo.display + '</option>';
}
for (i = 0; i < this.customFields.length; i++) {
fieldInfo = this.customFields[i];
// String has already been escaped using format_string.
html += '<option value="cf_' + fieldInfo.field + '">' + fieldInfo.display + '</option>';
}
html += '</select></label> <label><span class="accesshide">' + M.util.get_string('label_operator', 'availability_profile') +
' </span><select name="op" title="' + M.util.get_string('label_operator', 'availability_profile') + '"' +
' class="custom-select">';
var operators = ['isequalto', 'contains', 'doesnotcontain', 'startswith', 'endswith',
'isempty', 'isnotempty'];
for (i = 0; i < operators.length; i++) {
html += '<option value="' + operators[i] + '">' +
M.util.get_string('op_' + operators[i], 'availability_profile') + '</option>';
}
html += '</select></label> <label><span class="accesshide">' + M.util.get_string('label_value', 'availability_profile') +
'</span><input name="value" type="text" class="form-control" style="width: 10em" title="' +
M.util.get_string('label_value', 'availability_profile') + '"/></label></span>';
var node = Y.Node.create('<span class="d-flex flex-wrap align-items-center">' + html + '</span>');
// 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.cf + ']')) {
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.op === 'isempty' || json.op === 'isnotempty') {
node.one('input[name=value]').set('disabled', true);
}
}
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('.availability-field');
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');
}
};
}, '@VERSION@', {"requires": ["base", "node", "event", "moodle-core_availability-form"]});
@@ -0,0 +1 @@
YUI.add("moodle-availability_profile-form",function(n,e){M.availability_profile=M.availability_profile||{},M.availability_profile.form=n.Object(M.core_availability.plugin),M.availability_profile.form.profiles=null,M.availability_profile.form.initInner=function(e,i){this.standardFields=e,this.customFields=i},M.availability_profile.form.getNode=function(e){for(var i,l,a,t,o='<span class="availability-group"><label><span class="pr-3">'+M.util.get_string("conditiontitle","availability_profile")+'</span> <select name="field" class="custom-select"><option value="choose">'+M.util.get_string("choosedots","moodle")+"</option>",s=0;s<this.standardFields.length;s++)o+='<option value="sf_'+(i=this.standardFields[s]).field+'">'+i.display+"</option>";for(s=0;s<this.customFields.length;s++)o+='<option value="cf_'+(i=this.customFields[s]).field+'">'+i.display+"</option>";for(o+='</select></label> <label><span class="accesshide">'+M.util.get_string("label_operator","availability_profile")+' </span><select name="op" title="'+M.util.get_string("label_operator","availability_profile")+'" class="custom-select">',l=["isequalto","contains","doesnotcontain","startswith","endswith","isempty","isnotempty"],s=0;s<l.length;s++)o+='<option value="'+l[s]+'">'+M.util.get_string("op_"+l[s],"availability_profile")+"</option>";return o+='</select></label> <label><span class="accesshide">'+M.util.get_string("label_value","availability_profile")+'</span><input name="value" type="text" class="form-control" style="width: 10em" title="'+M.util.get_string("label_value","availability_profile")+'"/></label></span>',a=n.Node.create('<span class="d-flex flex-wrap align-items-center">'+o+"</span>"),e.sf!==undefined&&a.one("select[name=field] > option[value=sf_"+e.sf+"]")?a.one("select[name=field]").set("value","sf_"+e.sf):e.cf!==undefined&&a.one("select[name=field] > option[value=cf_"+e.cf+"]")&&a.one("select[name=field]").set("value","cf_"+e.cf),e.op!==undefined&&a.one("select[name=op] > option[value="+e.op+"]")&&(a.one("select[name=op]").set("value",e.op),"isempty"!==e.op&&"isnotempty"!==e.op||a.one("input[name=value]").set("disabled",!0)),e.v!==undefined&&a.one("input").set("value",e.v),M.availability_profile.form.addedEvents||(M.availability_profile.form.addedEvents=!0,t=function(e){var e=e.ancestor("span.availability_profile"),i=e.one("select[name=op]"),i="isempty"===i.get("value")||"isnotempty"===i.get("value");e.one("input[name=value]").set("disabled",i),M.core_availability.form.update()},(e=n.one(".availability-field")).delegate("change",function(){t(this)},".availability_profile select"),e.delegate("change",function(){t(this)},".availability_profile input[name=value]")),a},M.availability_profile.form.fillValue=function(e,i){var l=i.one("select[name=field]").get("value");"sf_"===l.substr(0,3)?e.sf=l.substr(3):"cf_"===l.substr(0,3)&&(e.cf=l.substr(3)),e.op=i.one("select[name=op]").get("value"),(l=i.one("input[name=value]")).get("disabled")||(e.v=l.get("value"))},M.availability_profile.form.fillErrors=function(e,i){var l={};this.fillValue(l,i),l.sf===undefined&&l.cf===undefined&&e.push("availability_profile:error_selectfield"),l.v!==undefined&&/^\s*$/.test(l.v)&&e.push("availability_profile:error_setvalue")}},"@VERSION@",{requires:["base","node","event","moodle-core_availability-form"]});
@@ -0,0 +1,139 @@
YUI.add('moodle-availability_profile-form', function (Y, NAME) {
/**
* 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 html = '<span class="availability-group"><label><span class="pr-3">' +
M.util.get_string('conditiontitle', 'availability_profile') + '</span> ' +
'<select name="field" class="custom-select">' +
'<option value="choose">' + M.util.get_string('choosedots', 'moodle') + '</option>';
var fieldInfo;
for (var i = 0; i < this.standardFields.length; i++) {
fieldInfo = this.standardFields[i];
// String has already been escaped using format_string.
html += '<option value="sf_' + fieldInfo.field + '">' + fieldInfo.display + '</option>';
}
for (i = 0; i < this.customFields.length; i++) {
fieldInfo = this.customFields[i];
// String has already been escaped using format_string.
html += '<option value="cf_' + fieldInfo.field + '">' + fieldInfo.display + '</option>';
}
html += '</select></label> <label><span class="accesshide">' + M.util.get_string('label_operator', 'availability_profile') +
' </span><select name="op" title="' + M.util.get_string('label_operator', 'availability_profile') + '"' +
' class="custom-select">';
var operators = ['isequalto', 'contains', 'doesnotcontain', 'startswith', 'endswith',
'isempty', 'isnotempty'];
for (i = 0; i < operators.length; i++) {
html += '<option value="' + operators[i] + '">' +
M.util.get_string('op_' + operators[i], 'availability_profile') + '</option>';
}
html += '</select></label> <label><span class="accesshide">' + M.util.get_string('label_value', 'availability_profile') +
'</span><input name="value" type="text" class="form-control" style="width: 10em" title="' +
M.util.get_string('label_value', 'availability_profile') + '"/></label></span>';
var node = Y.Node.create('<span class="d-flex flex-wrap align-items-center">' + html + '</span>');
// 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.cf + ']')) {
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.op === 'isempty' || json.op === 'isnotempty') {
node.one('input[name=value]').set('disabled', true);
}
}
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('.availability-field');
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');
}
};
}, '@VERSION@', {"requires": ["base", "node", "event", "moodle-core_availability-form"]});
@@ -0,0 +1,10 @@
{
"name": "moodle-availability_profile-form",
"builds": {
"moodle-availability_profile-form": {
"jsfiles": [
"form.js"
]
}
}
}
+134
View File
@@ -0,0 +1,134 @@
/**
* 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 html = '<span class="availability-group"><label><span class="pr-3">' +
M.util.get_string('conditiontitle', 'availability_profile') + '</span> ' +
'<select name="field" class="custom-select">' +
'<option value="choose">' + M.util.get_string('choosedots', 'moodle') + '</option>';
var fieldInfo;
for (var i = 0; i < this.standardFields.length; i++) {
fieldInfo = this.standardFields[i];
// String has already been escaped using format_string.
html += '<option value="sf_' + fieldInfo.field + '">' + fieldInfo.display + '</option>';
}
for (i = 0; i < this.customFields.length; i++) {
fieldInfo = this.customFields[i];
// String has already been escaped using format_string.
html += '<option value="cf_' + fieldInfo.field + '">' + fieldInfo.display + '</option>';
}
html += '</select></label> <label><span class="accesshide">' + M.util.get_string('label_operator', 'availability_profile') +
' </span><select name="op" title="' + M.util.get_string('label_operator', 'availability_profile') + '"' +
' class="custom-select">';
var operators = ['isequalto', 'contains', 'doesnotcontain', 'startswith', 'endswith',
'isempty', 'isnotempty'];
for (i = 0; i < operators.length; i++) {
html += '<option value="' + operators[i] + '">' +
M.util.get_string('op_' + operators[i], 'availability_profile') + '</option>';
}
html += '</select></label> <label><span class="accesshide">' + M.util.get_string('label_value', 'availability_profile') +
'</span><input name="value" type="text" class="form-control" style="width: 10em" title="' +
M.util.get_string('label_value', 'availability_profile') + '"/></label></span>';
var node = Y.Node.create('<span class="d-flex flex-wrap align-items-center">' + html + '</span>');
// 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.cf + ']')) {
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.op === 'isempty' || json.op === 'isnotempty') {
node.one('input[name=value]').set('disabled', true);
}
}
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('.availability-field');
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');
}
};
@@ -0,0 +1,10 @@
{
"moodle-availability_profile-form": {
"requires": [
"base",
"node",
"event",
"moodle-core_availability-form"
]
}
}