diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..cabc576d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,53 @@ +> **Note:** Please fill out all relevant sections and remove irrelevant ones. +### 🔀 Purpose of this PR: + +- [ ] Fixes a bug +- [ ] Updates for a new Moodle version +- [ ] Adds a new feature of functionality +- [ ] Improves or enhances existing features +- [ ] Refactoring: restructures code for better performance or maintainability +- [ ] Testing: add missing or improve existing tests +- [ ] Miscellaneous: code cleaning (without functional changes), documentation, configuration, ... + +--- + +### 📝 Description: + +Please describe the purpose of this PR in a few sentences. + +- What feature or bug does it address? +- Why is this change or addition necessary? +- What is the expected behavior after the change? + +--- + +### 📋 Checklist + +Please confirm the following (check all that apply): + +- [ ] I have `phpunit` and/or `behat` tests that cover my changes or additions. +- [ ] Code passes the code checker without errors and warnings. +- [ ] Code passes the moodle-ci/cd pipeline on all supported Moodle versions or the ones the plugin supports. +- [ ] Code does not have `var_dump()` or `var_export` or any other debugging statements (or commented out code) that + should not appear on the productive branch. +- [ ] Code only uses language strings instead of hard-coded strings. +- [ ] If there are changes in the database: I updated/created the necessary upgrade steps in `db/upgrade.php` and + updated the `version.php`. +- [ ] If there are changes in javascript: I build new `.min` files with the `grunt amd` command. +- [ ] If it is a Moodle update PR: I read the release notes, updated the `version.php` and the `CHANGES.md`. + I ran all tests thoroughly checking for errors. I checked if bootstrap had any changes/deprecations that require + changes in the plugins UI. + +--- + +### 🔍 Related Issues + +- Related to #[IssueNumber] + +--- + +### 🧾📸🌐 Additional Information (like screenshots, documentation, links, etc.) + +Any other relevant information. + +--- \ No newline at end of file diff --git a/amd/build/delselected.min.js b/amd/build/delselected.min.js index e1cfe497..efd03f6f 100644 --- a/amd/build/delselected.min.js +++ b/amd/build/delselected.min.js @@ -6,6 +6,6 @@ define("mod_scheduler/delselected",["exports"],(function(_exports){Object.define * @copyright 2024 ISB Bayern * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -const SELECTORS={DELACTION:"div.commandbar a#delselected",SELECTBOX:"table#slotmanager input.slotselect"};_exports.SELECTORS=SELECTORS;_exports.init=baseurl=>{let link=document.querySelector(SELECTORS.DELACTION);null!==link&&link.addEventListener("click",(function(){collectSelection(link,baseurl)}))};const collectSelection=(link,baseurl)=>{let sellist="";document.querySelectorAll(SELECTORS.SELECTBOX).forEach((function(box){box.checked&&(sellist.length>0&&(sellist+=","),sellist+=box.getAttribute("value"))})),link.setAttribute("href",baseurl+"&items="+sellist)};_exports.collectSelection=collectSelection})); +const SELECTORS={DELACTION:"div.commandbar a#delselected",SELECTBOX:"table#slotmanager input.slotselect.form-check-input.mt-0"};_exports.SELECTORS=SELECTORS;_exports.init=baseurl=>{let link=document.querySelector(SELECTORS.DELACTION);null!==link&&link.addEventListener("click",(function(){collectSelection(link,baseurl)}))};const collectSelection=(link,baseurl)=>{let sellist="";document.querySelectorAll(SELECTORS.SELECTBOX).forEach((function(box){box.checked&&(sellist.length>0&&(sellist+=","),sellist+=box.getAttribute("value"))})),link.setAttribute("href",baseurl+"&items="+sellist)};_exports.collectSelection=collectSelection})); //# sourceMappingURL=delselected.min.js.map \ No newline at end of file diff --git a/amd/build/delselected.min.js.map b/amd/build/delselected.min.js.map index 0b68088b..54de28df 100644 --- a/amd/build/delselected.min.js.map +++ b/amd/build/delselected.min.js.map @@ -1 +1 @@ -{"version":3,"file":"delselected.min.js","sources":["../src/delselected.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * TODO describe module delselected\n *\n * @module mod_scheduler/delselected\n * @copyright 2024 ISB Bayern\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n/**\n * Selectors used by the module.\n */\nexport const SELECTORS = {\n DELACTION: 'div.commandbar a#delselected',\n SELECTBOX: 'table#slotmanager input.slotselect',\n};\n\n/**\n * Add event listener to the delete link.\n * @param {*} baseurl\n */\nexport const init = (baseurl) => {\n let link = document.querySelector(SELECTORS.DELACTION);\n if (link !== null) {\n link.addEventListener('click', function() {\n collectSelection(link, baseurl);\n });\n }\n};\n\n/**\n * Copy the selected boxes into an input parameter of the respective form\n *\n * @param {String} link\n * @param {String} baseurl\n */\nexport const collectSelection = (link, baseurl) => {\n let sellist = '';\n document.querySelectorAll(SELECTORS.SELECTBOX).forEach(function(box) {\n if (box.checked) {\n if (sellist.length > 0) {\n sellist += ',';\n }\n sellist += box.getAttribute('value');\n }\n });\n link.setAttribute('href', baseurl + '&items=' + sellist);\n};\n"],"names":["SELECTORS","DELACTION","SELECTBOX","baseurl","link","document","querySelector","addEventListener","collectSelection","sellist","querySelectorAll","forEach","box","checked","length","getAttribute","setAttribute"],"mappings":";;;;;;;;MA0BaA,UAAY,CACrBC,UAAW,+BACXC,UAAW,iFAOMC,cACbC,KAAOC,SAASC,cAAcN,UAAUC,WAC/B,OAATG,MACAA,KAAKG,iBAAiB,SAAS,WAC3BC,iBAAiBJ,KAAMD,mBAWtBK,iBAAmB,CAACJ,KAAMD,eAC/BM,QAAU,GACdJ,SAASK,iBAAiBV,UAAUE,WAAWS,SAAQ,SAASC,KACxDA,IAAIC,UACAJ,QAAQK,OAAS,IACjBL,SAAW,KAEfA,SAAWG,IAAIG,aAAa,aAGpCX,KAAKY,aAAa,OAAQb,QAAU,UAAYM"} \ No newline at end of file +{"version":3,"file":"delselected.min.js","sources":["../src/delselected.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * TODO describe module delselected\n *\n * @module mod_scheduler/delselected\n * @copyright 2024 ISB Bayern\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n/**\n * Selectors used by the module.\n */\nexport const SELECTORS = {\n DELACTION: 'div.commandbar a#delselected',\n SELECTBOX: 'table#slotmanager input.slotselect.form-check-input.mt-0',\n};\n\n/**\n * Add event listener to the delete link.\n * @param {*} baseurl\n */\nexport const init = (baseurl) => {\n let link = document.querySelector(SELECTORS.DELACTION);\n if (link !== null) {\n link.addEventListener('click', function() {\n collectSelection(link, baseurl);\n });\n }\n};\n\n/**\n * Copy the selected boxes into an input parameter of the respective form\n *\n * @param {String} link\n * @param {String} baseurl\n */\nexport const collectSelection = (link, baseurl) => {\n let sellist = '';\n document.querySelectorAll(SELECTORS.SELECTBOX).forEach(function(box) {\n if (box.checked) {\n if (sellist.length > 0) {\n sellist += ',';\n }\n sellist += box.getAttribute('value');\n }\n });\n link.setAttribute('href', baseurl + '&items=' + sellist);\n};\n"],"names":["SELECTORS","DELACTION","SELECTBOX","baseurl","link","document","querySelector","addEventListener","collectSelection","sellist","querySelectorAll","forEach","box","checked","length","getAttribute","setAttribute"],"mappings":";;;;;;;;MA0BaA,UAAY,CACrBC,UAAW,+BACXC,UAAW,uGAOMC,cACbC,KAAOC,SAASC,cAAcN,UAAUC,WAC/B,OAATG,MACAA,KAAKG,iBAAiB,SAAS,WAC3BC,iBAAiBJ,KAAMD,mBAWtBK,iBAAmB,CAACJ,KAAMD,eAC/BM,QAAU,GACdJ,SAASK,iBAAiBV,UAAUE,WAAWS,SAAQ,SAASC,KACxDA,IAAIC,UACAJ,QAAQK,OAAS,IACjBL,SAAW,KAEfA,SAAWG,IAAIG,aAAa,aAGpCX,KAAKY,aAAa,OAAQb,QAAU,UAAYM"} \ No newline at end of file diff --git a/amd/src/delselected.js b/amd/src/delselected.js index 462f867d..59ddbce9 100644 --- a/amd/src/delselected.js +++ b/amd/src/delselected.js @@ -26,7 +26,7 @@ */ export const SELECTORS = { DELACTION: 'div.commandbar a#delselected', - SELECTBOX: 'table#slotmanager input.slotselect', + SELECTBOX: 'table#slotmanager input.slotselect.form-check-input.mt-0', }; /** diff --git a/backup/moodle2/backup_scheduler_stepslib.php b/backup/moodle2/backup_scheduler_stepslib.php index 6d6f938f..73a571a6 100644 --- a/backup/moodle2/backup_scheduler_stepslib.php +++ b/backup/moodle2/backup_scheduler_stepslib.php @@ -36,30 +36,56 @@ class backup_scheduler_activity_structure_step extends backup_activity_structure * @return backup_nested_element */ protected function define_structure() { + global $DB; // To know if we are including userinfo. $userinfo = $this->get_setting_value('userinfo'); + $task = $this->get_task(); + + $backupinfo = $DB->get_record( + 'backup_controllers', + ['backupid' => $task->get_backupid()], + 'type, purpose, interactive', + MUST_EXIST + ); + + $isduplicate = $backupinfo->type === backup::TYPE_1ACTIVITY + && (int) $backupinfo->purpose === backup::MODE_IMPORT + && (bool) $backupinfo->interactive === backup::INTERACTIVE_NO; + + $copyslots = false; + if ($isduplicate) { + $copyslots = (bool) $DB->get_field( + 'scheduler', + 'copyslotsonduplicate', + ['id' => $task->get_activityid()], + MUST_EXIST + ); + } + + $includeslots = $userinfo || $copyslots; + // Define each element separated. $scheduler = new backup_nested_element('scheduler', ['id'], [ 'name', 'intro', 'introformat', 'schedulermode', 'maxbookings', 'guardtime', 'defaultslotduration', 'allownotifications', 'staffrolename', - 'scale', 'gradingstrategy', 'bookingrouping', 'usenotes', + 'scale', 'gradingstrategy', 'bookingrouping', 'bookingtype', 'usenotes', 'usebookingform', 'bookinginstructions', 'bookinginstructionsformat', 'usestudentnotes', 'requireupload', 'uploadmaxfiles', 'uploadmaxsize', - 'usecaptcha', 'timemodified', ]); + 'usecaptcha', 'timemodified', 'groupcreation', 'copyslotsonduplicate', ]); $slots = new backup_nested_element('slots'); $slot = new backup_nested_element('slot', ['id'], [ - 'starttime', 'duration', 'teacherid', 'appointmentlocation', + 'starttime', 'duration', 'teacherid', 'appointmentlocation', 'meetingurl', 'timemodified', 'notes', 'notesformat', 'exclusivity', - 'emaildate', 'hideuntil', ]); + 'emaildate', 'hideuntil', 'coursegroupid', ]); $appointments = new backup_nested_element('appointments'); $appointment = new backup_nested_element('appointment', ['id'], [ - 'studentid', 'attended', 'grade', + 'studentid', 'bookinggroupid', 'attended', 'grade', 'appointmentnote', 'appointmentnoteformat', 'teachernote', 'teachernoteformat', 'studentnote', 'studentnoteformat', 'timecreated', 'timemodified', ]); @@ -75,18 +101,26 @@ protected function define_structure() { $scheduler->set_source_table('scheduler', ['id' => backup::VAR_ACTIVITYID]); $scheduler->annotate_ids('grouping', 'bookingrouping'); - // Include appointments only if we back up user information. - if ($userinfo) { + // Include slots when backing up user data or explicitly copying slots. + if ($includeslots) { $slot->set_source_table('scheduler_slots', ['schedulerid' => backup::VAR_PARENTID]); + } + + // Include bookings and personal appointment data only with user data. + if ($userinfo) { $appointment->set_source_table('scheduler_appointment', ['slotid' => backup::VAR_PARENTID]); } // Define id annotations. $scheduler->annotate_ids('scale', 'scale'); + if ($includeslots) { + $slot->annotate_ids('group', 'coursegroupid'); + } if ($userinfo) { $slot->annotate_ids('user', 'teacherid'); $appointment->annotate_ids('user', 'studentid'); + $appointment->annotate_ids('group', 'bookinggroupid'); } // Define file annotations. diff --git a/backup/moodle2/restore_scheduler_stepslib.php b/backup/moodle2/restore_scheduler_stepslib.php index 813a7672..dcf4a1b5 100644 --- a/backup/moodle2/restore_scheduler_stepslib.php +++ b/backup/moodle2/restore_scheduler_stepslib.php @@ -30,14 +30,51 @@ */ class restore_scheduler_activity_structure_step extends restore_activity_structure_step { + /** @var bool Whether this is a duplication within the same course. */ + protected $isduplicate = false; + + /** @var bool Whether slots may be copied without appointment data. */ + protected $copyslots = false; + /** * define_structure * * @return array */ protected function define_structure() { + global $DB; + + $userinfo = $this->get_setting_value('userinfo'); + $task = $this->get_task(); + + $restoreinfo = $DB->get_record( + 'backup_controllers', + ['backupid' => $task->get_restoreid()], + 'type, purpose, interactive', + MUST_EXIST + ); + + $this->isduplicate = + $restoreinfo->type === backup::TYPE_1ACTIVITY + && (int) $restoreinfo->purpose === backup::MODE_IMPORT + && (bool) $restoreinfo->interactive === backup::INTERACTIVE_NO + && $task->is_samesite() + && (int) $task->get_old_courseid() === (int) $this->get_courseid(); + + $paths = [new restore_path_element('scheduler', '/activity/scheduler')]; + if ($userinfo || $this->isduplicate) { + $paths[] = new restore_path_element('scheduler_slot', '/activity/scheduler/slots/slot'); + } + + if ($userinfo) { + $paths[] = new restore_path_element( + 'scheduler_appointment', + '/activity/scheduler/slots/slot/appointments/appointment' + ); + } + + return $this->prepare_activity_structure($paths); - $paths = []; $userinfo = $this->get_setting_value('userinfo'); $scheduler = new restore_path_element('scheduler', '/activity/scheduler'); @@ -80,10 +117,27 @@ protected function process_scheduler($data) { $data->gradingstrategy = 0; } + if (!isset($data->bookingtype)) { + $data->bookingtype = get_config('mod_scheduler', 'mixindivgroup') ? 1 : 2; + } + if ($data->bookingrouping > 0) { - $data->bookingrouping = $this->get_mappingid('grouping', $data->bookingrouping); + if ($this->isduplicate) { + $DB->get_record( + 'groupings', + ['id' => $data->bookingrouping, 'courseid' => $this->get_courseid()], + 'id', + MUST_EXIST + ); + } else { + $data->bookingrouping = $this->get_mappingid('grouping', $data->bookingrouping); + } } + $data->groupcreation = (int) ($data->groupcreation ?? 0); + $data->copyslotsonduplicate = (int) ($data->copyslotsonduplicate ?? 0); + $this->copyslots = $this->isduplicate && !empty($data->copyslotsonduplicate); + // Insert the scheduler record. $newitemid = $DB->insert_record('scheduler', $data); // Immediately after inserting "activity" record, call this. @@ -98,7 +152,12 @@ protected function process_scheduler($data) { protected function process_scheduler_slot($data) { global $DB; + $userinfo = $this->get_setting_value('userinfo'); + if (!$userinfo && !$this->copyslots) { + return self::SKIP_ALL_CHILDREN; + } $data = (object)$data; + $data->meetingurl = $data->meetingurl ?? ''; $oldid = $data->id; $data->schedulerid = $this->get_new_parentid('scheduler'); @@ -106,8 +165,37 @@ protected function process_scheduler_slot($data) { $data->timemodified = $this->apply_date_offset($data->timemodified); $data->emaildate = $this->apply_date_offset($data->emaildate); $data->hideuntil = $this->apply_date_offset($data->hideuntil); - - $data->teacherid = $this->get_mappingid('user', $data->teacherid); + if ($this->copyslots && !$userinfo) { + // Same-site, same-course duplication retains existing references. + if (!empty($data->teacherid)) { + $DB->get_record( + 'user', + ['id' => $data->teacherid, 'deleted' => 0], + 'id', + MUST_EXIST + ); + } + + $data->coursegroupid = (int) ($data->coursegroupid ?? 0); + + if ($data->coursegroupid > 0) { + $DB->get_record( + 'groups', + [ + 'id' => $data->coursegroupid, + 'courseid' => $this->get_courseid(), + ], + 'id', + MUST_EXIST + ); + } + } else { + $data->teacherid = $this->get_mappingid('user', $data->teacherid); + + $data->coursegroupid = !empty($data->coursegroupid) + ? $this->get_mappingid('group', $data->coursegroupid, 0) + : 0; + } $newitemid = $DB->insert_record('scheduler_slots', $data); $this->set_mapping('scheduler_slot', $oldid, $newitemid, true); @@ -131,6 +219,9 @@ protected function process_scheduler_appointment($data) { $data->studentid = $this->get_mappingid('user', $data->studentid); + $data->bookinggroupid = !empty($data->bookinggroupid) + ? $this->get_mappingid('group', $data->bookinggroupid, 0) : 0; + $newitemid = $DB->insert_record('scheduler_appointment', $data); $this->set_mapping('scheduler_appointment', $oldid, $newitemid, true); } diff --git a/classes/course_group_helper.php b/classes/course_group_helper.php new file mode 100644 index 00000000..cdbc80ef --- /dev/null +++ b/classes/course_group_helper.php @@ -0,0 +1,113 @@ +. + +/** + * Helpers for synchronizing generated course groups with slot bookings. + * + * @package mod_scheduler + * @copyright 2026 L. Herfeldt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scheduler; +/** + * Updates generated course group memberships from individual slot bookings. + */ +class course_group_helper { + /** + * Adds individually booked students to the slot's generated course group. + * + * @param model\slot $slot + * @return void + * @throws \coding_exception + * @throws \dml_exception + */ + public static function add_booked_students(\mod_scheduler\model\slot $slot): void { + global $CFG; + global $DB; + + $scheduler = $slot->get_scheduler(); + $mode = (int) $scheduler->groupcreation; + + // Only process automatic group creation modes. + if (!in_array($mode, [1, 2], true)) { + return; + } + + // Slot has to allow multiple bookers (groupslot). + if (!$slot->is_groupslot()) { + return; + } + + $studentids = $DB->get_fieldset_select( + 'scheduler_appointment', + 'DISTINCT studentid', + 'slotid = :slotid AND bookinggroupid = 0', + ['slotid' => $slot->id] + ); + + if (!$studentids) { + return; + } + + require_once($CFG->dirroot . '/mod/scheduler/locallib.php'); + require_once($CFG->dirroot . '/group/lib.php'); + + $groupid = \scheduler_create_coursegroup($slot); + foreach ($studentids as $studentid) { + \groups_add_member($groupid, $studentid); + } + } + + /** + * Removes individually booked students from the slot's generated course group. + * + * @param model\slot $slot + * @param \stdClass $appointment + * @return void + * @throws \dml_exception + */ + public static function remove_booked_students(\mod_scheduler\model\slot $slot, \stdClass $appointment): void { + global $CFG, $DB; + + // Existing group bookings do not control generated group memberships. + if ((int) $appointment->bookinggroupid !== 0) { + return; + } + + $scheduler = $slot->get_scheduler(); + $mode = (int) $scheduler->groupcreation; + + // Only process automatic group creation modes. + if (!in_array($mode, [1, 2], true)) { + return; + } + + $groupid = $DB->get_field( + 'scheduler_slots', + 'coursegroupid', + ['id' => $slot->id] + ); + + if (!$groupid || !$DB->record_exists('groups', ['id' => $groupid, 'courseid' => $scheduler->course])) { + return; + } + + require_once($CFG->dirroot . '/group/lib.php'); + + \groups_remove_member($groupid, (int) $appointment->studentid); + } +} diff --git a/classes/courseformat/overview.php b/classes/courseformat/overview.php new file mode 100644 index 00000000..ee622279 --- /dev/null +++ b/classes/courseformat/overview.php @@ -0,0 +1,192 @@ +. + +namespace mod_scheduler\courseformat; + +use mod_scheduler\model\scheduler; +use core\output\action_link; +use core\output\local\properties\button; +use core\output\local\properties\text_align; +use core\url; +use core_courseformat\local\overview\overviewitem; + +/** + * Scheduler overview integration (for Moodle 5.1+) + * + * @package mod_scheduler + * @copyright 2025 Luca Bösch + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class overview extends \core_courseformat\activityoverviewbase { + /** @var scheduler $scheduler the scheduler instance. */ + private scheduler $scheduler; + + /** + * Constructor. + * + * @param \cm_info $cm the course module instance. + * @param \core\output\renderer_helper $rendererhelper the renderer helper. + */ + public function __construct( + \cm_info $cm, + /** @var \core\output\renderer_helper $rendererhelper the renderer helper */ + protected readonly \core\output\renderer_helper $rendererhelper, + ) { + global $CFG; + require_once($CFG->dirroot . '/mod/scheduler/locallib.php'); + parent::__construct($cm); + $this->scheduler = \mod_scheduler\model\scheduler::load_by_coursemodule_id($cm->id); + } + + #[\Override] + public function get_actions_overview(): ?overviewitem { + $url = new url( + '/mod/scheduler/view.php', + ['id' => $this->cm->id], + ); + + $text = get_string('view'); + + if ( + class_exists(button::class) && + (new \ReflectionClass(button::class))->hasConstant('BODY_OUTLINE') + ) { + $bodyoutline = button::BODY_OUTLINE; + $buttonclass = $bodyoutline->classes(); + } else { + $buttonclass = "btn btn-outline-secondary"; + } + + $content = new action_link($url, $text, null, ['class' => $buttonclass]); + return new overviewitem(get_string('actions'), $text, $content, text_align::CENTER); + } + + #[\Override] + public function get_extra_overview_items(): array { + return [ + 'openappointments' => $this->get_extra_open_appointments_overview(), + 'appointmentstatus' => $this->get_extra_appointment_status_overview(), + ]; + } + + /** + * Retrieves an overview of submissions for the assignment. + * + * @return overviewitem|null An overview item c, or null if the user lacks the required capability. + */ + private function get_extra_open_appointments_overview(): ?overviewitem { + global $USER; + + if (!has_capability('mod/scheduler:manageallappointments', $this->cm->context)) { + return null; + } + + if ( + class_exists(button::class) && + (new \ReflectionClass(button::class))->hasConstant('BODY_OUTLINE') + ) { + $bodyoutline = button::BODY_OUTLINE; + $buttonclass = $bodyoutline->classes(); + } else { + $buttonclass = "btn btn-outline-secondary"; + } + + $groupscheduling = $this->scheduler->is_group_scheduling_enabled(); + if (!$groupscheduling) { + $openappointments = $this->scheduler->get_students_for_scheduling(); + if (is_array($openappointments)) { + $openappointments = count($openappointments); + } + $total = count($this->scheduler->get_available_students()); + + $content = new action_link( + url: new url( + '/mod/scheduler/view.php', + [ + 'id' => $this->cm->id, + 'what' => 'view', + 'scope' => 'activity', + 'subpage' => 'allappointments', + ] + ), + text: get_string( + 'count_of_total', + 'core', + ['count' => $openappointments, 'total' => $total] + ), + attributes: ['class' => $buttonclass], + ); + } else { + $openappointments = count($this->scheduler->get_groups_for_scheduling()); + $total = count($this->scheduler->get_available_groups()); + + $content = new action_link( + url: new url( + '/mod/scheduler/view.php', + [ + 'id' => $this->cm->id, + 'what' => 'view', + 'scope' => 'activity', + 'subpage' => 'allappointments', + ] + ), + text: get_string( + 'count_of_total_groups', + 'scheduler', + ['count' => $openappointments, 'total' => $total] + ), + attributes: ['class' => $buttonclass], + ); + } + + return new overviewitem( + name: get_string('needmakeappointment', 'scheduler'), + value: $openappointments, + content: $content, + textalign: text_align::CENTER, + ); + } + + /** + * Retrieves the appointment status overview for the current user. + * + * @return overviewitem|null The overview item, or null if the user does not have the required capabilities. + */ + private function get_extra_appointment_status_overview(): ?overviewitem { + global $USER; + + if ( + !has_capability('mod/scheduler:appoint', $this->context, $USER, false) || + has_capability('mod/scheduler:manage', $this->context, $USER, false) + ) { + return null; + } + + $userappointment = $this->scheduler->get_appointments_for_student($USER->id); + + if (!empty($userappointment)) { + $appointmentstatus = get_string('appointmentstatus_appointed', 'scheduler'); + } else { + $appointmentstatus = get_string('appointmentstatus_unappointed', 'scheduler'); + } + + return new overviewitem( + name: get_string('appointmentstatus', 'scheduler'), + value: $appointmentstatus, + content: $appointmentstatus, + ); + } +} diff --git a/classes/event/booking_removed.php b/classes/event/booking_removed.php index 39885346..02ef1826 100644 --- a/classes/event/booking_removed.php +++ b/classes/event/booking_removed.php @@ -39,10 +39,22 @@ class booking_removed extends slot_base * Create this event on a given slot. * * @param \mod_scheduler\model\slot $slot + * @param \stdClass|null $appointment Cancelled appointment data. * @return \core\event\base */ - public static function create_from_slot(\mod_scheduler\model\slot $slot) { - $event = self::create(self::base_data($slot)); + public static function create_from_slot( + \mod_scheduler\model\slot $slot, + ?\stdClass $appointment = null + ) { + $data = self::base_data($slot); + + if ($appointment !== null) { + $data['other'] = [ + 'studentid' => (int) $appointment->studentid, + 'bookinggroupid' => (int) $appointment->bookinggroupid, + ]; + } + $event = self::create($data); $event->set_slot($slot); return $event; } diff --git a/classes/manager.php b/classes/manager.php new file mode 100644 index 00000000..fe4fd28e --- /dev/null +++ b/classes/manager.php @@ -0,0 +1,164 @@ +. + +namespace mod_scheduler; + +use cm_info; +use context_module; +use stdClass; + +/** + * Class manager for scheduler activity + * + * @package mod_scheduler + * @copyright 2025 Luca Bösch + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class manager { + /** Module name. */ + public const MODULE = 'scheduler'; + + /** @var context_module the current context. */ + private $context; + + /** @var stdClass $course record. */ + private $course; + + /** @var \moodle_database the database instance. */ + private \moodle_database $db; + + /** + * Class constructor. + * + * @param cm_info $cm course module info object + * @param stdClass $instance activity instance object. + */ + public function __construct( + /** @var cm_info $cm the given course module info */ + private cm_info $cm, + /** @var stdClass $instance activity instance object */ + private stdClass $instance + ) { + $this->context = context_module::instance($cm->id); + $this->db = \core\di::get(\moodle_database::class); + $this->course = $cm->get_course(); + } + + /** + * Create a manager instance from an instance record. + * + * @param stdClass $instance an activity record + * @return manager + */ + public static function create_from_instance(stdClass $instance): self { + $cm = get_coursemodule_from_instance(self::MODULE, $instance->id); + // Ensure that $this->cm is a cm_info object. + $cm = cm_info::create($cm); + return new self($cm, $instance); + } + + /** + * Create a manager instance from a course_modules record. + * + * @param stdClass|cm_info $cm an activity record + * @return manager + */ + public static function create_from_coursemodule(stdClass|cm_info $cm): self { + // Ensure that $this->cm is a cm_info object. + $cm = cm_info::create($cm); + $db = \core\di::get(\moodle_database::class); + $instance = $db->get_record(self::MODULE, ['id' => $cm->instance], '*', MUST_EXIST); + return new self($cm, $instance); + } + + /** + * Return the current context. + * + * @return context_module + */ + public function get_context(): context_module { + return $this->context; + } + + /** + * Return the current instance. + * + * @return stdClass the instance record + */ + public function get_instance(): stdClass { + return $this->instance; + } + + /** + * Return the current cm_info. + * + * @return cm_info the course module + */ + public function get_coursemodule(): cm_info { + return $this->cm; + } + + /** + * Return the current count of users who have booked slots in this scheduler module, that the current user can see. + * + * @param int[] $groupids the group identifiers to filter by, empty array means no filtering + * @param int|null $optionid the option ID to filter by, or null to count all answers + * @return int the number of answers that the user can see + */ + public function count_all_users_answered( + array $groupids = [], + ?int $optionid = null, + ): int { + if (!has_capability('mod/scheduler:manage', $this->context)) { + return 0; + } + + $tableprefix = empty($groupids) ? '' : 'of.'; + $select = $tableprefix . 'scheduler = :schedulerid'; + $params = [ + 'schedulerid' => $this->instance->id, + ]; + if ($optionid) { + $select .= ' AND ' . $tableprefix . 'optionid = :optionid '; + $params['optionid'] = $optionid; + } + + if (empty($groupids)) { + // No groups filtering, count all users answered. + return $this->db->count_records_select('scheduler_file', $select, $params, 'COUNT(DISTINCT userid)'); + } + + // Groups filtering is applied. + [$gsql, $gparams] = $this->db->get_in_or_equal($groupids, SQL_PARAMS_NAMED); + $query = "SELECT COUNT(DISTINCT of.userid) + FROM {scheduler_file} of, {groups_members} gm + WHERE $select + AND (gm.groupid $gsql OR gm.groupid = 0) + AND of.userid = gm.userid"; + return $this->db->count_records_sql($query, $params + $gparams); + } + + /** + * Check if the current user has booked a slot in this scheduler. + * + * @return bool true if the user has booked, false otherwise + */ + public function has_answered(): bool { + global $USER; + $conditions = ['scheduler' => $this->instance->id, 'userid' => $USER->id]; + return $this->db->record_exists('scheduler_file', $conditions); + } +} diff --git a/classes/model/appointment.php b/classes/model/appointment.php index 8ad2e655..df8fa5a5 100644 --- a/classes/model/appointment.php +++ b/classes/model/appointment.php @@ -59,6 +59,7 @@ public function __construct(slot $slot) { $this->data->attended = 0; $this->data->appointmentnoteformat = FORMAT_HTML; $this->data->teachernoteformat = FORMAT_HTML; + $this->data->bookinggroupid = 0; } /** diff --git a/classes/model/scheduler.php b/classes/model/scheduler.php index a4f043bc..dcced257 100644 --- a/classes/model/scheduler.php +++ b/classes/model/scheduler.php @@ -267,7 +267,6 @@ public function get_default_slot_duration() { * @return boolean */ public function is_group_scheduling_enabled() { - global $CFG; $globalenable = (bool) get_config('mod_scheduler', 'groupscheduling'); $localenable = $this->bookingrouping >= 0; return $globalenable && $localenable; @@ -275,14 +274,15 @@ public function is_group_scheduling_enabled() { /** * Retrieve whether individual scheduling is enabled in this instance. - * This is usually the case, but is disabled if the instance uses group scheduling - * and the configuration setting 'mixindivgroup' is set to inactive. + * Individual bookings are always allowed when group scheduling is disabled. + * Otherwise, the instance must allow both individual and group bookings. * * @return boolean */ public function is_individual_scheduling_enabled() { if ($this->is_group_scheduling_enabled()) { - return (bool) get_config('mod_scheduler', 'mixindivgroup'); + $bookingtype = $this->bookingtype; + return $bookingtype == 1; } else { return true; } @@ -755,7 +755,7 @@ public function get_slots_available_to_student($studentid, $includefullybooked = global $DB; $params = []; - $wherecond = "(s.starttime > :cutofftime) AND (s.hideuntil < :nowhide)"; + $wherecond = "(s.starttime > :cutofftime) AND (s.hideuntil <= :nowhide)"; $params['nowhide'] = time(); $params['cutofftime'] = time() + $this->guardtime; $subcond = 'NOT (' . $this->student_in_slot_condition($params, $studentid, false, false) . ')'; @@ -825,9 +825,8 @@ public function has_slots_booked_for_group($groupid, $mustbeattended = false, $m $sql = "SELECT COUNT(*) FROM {scheduler_slots} s JOIN {scheduler_appointment} a ON a.slotid = s.id - JOIN {groups_members} gm ON a.studentid = gm.userid WHERE s.schedulerid = :schedulerid - AND gm.groupid = :groupid + AND a.bookinggroupid = :groupid $attendcond"; $params = ['schedulerid' => $this->id, 'groupid' => $groupid]; return $DB->count_records_sql($sql, $params) > 0; @@ -1204,6 +1203,15 @@ public function has_available_teachers() { return count($teachers) > 0; } + /** + * Get list of available groups (i.e., groups that can book slots) + * + * @return \stdClass[] array of moodle group records + */ + public function get_available_groups() { + return groups_get_all_groups($this->courseid, 0, $this->bookingrouping); + } + /** * Get a list of students that can still make an appointment. * @@ -1241,6 +1249,28 @@ public function get_students_for_scheduling($groups = '', $cutoff = 0, $onlymand return $schedstuds; } + /** + * Get a list of student groups that can still make an appointment. + * + * @return int|array of moodle group records; or int 0 if there are no groups in the course. + */ + public function get_groups_for_scheduling() { + // Get all groups that can book slots. + $groups = $this->get_available_groups(); + + foreach ($groups as $groupid => $group) { + $groupalreadybooked = $this->has_slots_booked_for_group( + $group->id, + false, + $this->schedulermode === 'onetime' + ); + + if ($groupalreadybooked) { + unset($groups[$groupid]); + } + } + return $groups; + } /** * Delete an appointment, and do whatever is needed diff --git a/classes/model/slot.php b/classes/model/slot.php index c8af7473..ceb05fc1 100644 --- a/classes/model/slot.php +++ b/classes/model/slot.php @@ -393,9 +393,13 @@ private function update_calendar() { $myappointments = $this->appointments->get_children(); + $keepattendedincalendar = (bool) get_config('mod_scheduler', 'keepattendedincalendar'); + $studentids = []; foreach ($myappointments as $appointment) { - if (!$appointment->is_attended()) { + // Per default: Bookings remain calendar events after attendance is recorded. + // The admin can set the config to not keep attended appointments in the calendar. + if ($keepattendedincalendar || !$appointment->is_attended()) { $studentids[] = $appointment->studentid; } } diff --git a/classes/observer.php b/classes/observer.php new file mode 100644 index 00000000..29841c33 --- /dev/null +++ b/classes/observer.php @@ -0,0 +1,89 @@ +. + +/** + * Observer for mod_scheduler. + * + * @package mod_scheduler + * @copyright 2017 Henning Bostelmann and others (see README.txt) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scheduler; +/** + * Event observers for scheduler. + */ +class observer { + /** + * Creates a course group when slot is added. + * + * @param event\slot_added $event + * @return void + * @throws \coding_exception + * @throws \dml_exception + */ + public static function slot_added(\mod_scheduler\event\slot_added $event): void { + global $CFG; + + $slot = $event->get_slot(); + $scheduler = $slot->get_scheduler(); + + // Create group upon slot creation only for group creation mode 1. + if ((int) $scheduler->groupcreation !== 1) { + return; + } + + // Slot has to allow multiple bookers (groupslot). + if (!$slot->is_groupslot()) { + return; + } + + require_once($CFG->dirroot . '/mod/scheduler/locallib.php'); + + scheduler_create_coursegroup($slot); + } + + /** + * Adds booked students to the appropriate course group. + * + * @param \mod_scheduler\event\booking_added $event + * @return void + */ + public static function booking_added(\mod_scheduler\event\booking_added $event): void { + course_group_helper::add_booked_students($event->get_slot()); + } + + /** + * Removes students from the course group. + * + * @param event\booking_removed $event + * @return void + */ + public static function booking_removed(\mod_scheduler\event\booking_removed $event): void { + $other = $event->other; + + if (!isset($other['studentid'], $other['bookinggroupid'])) { + return; + } + + $appointment = (object) [ + 'studentid' => (int) $other['studentid'], + 'bookinggroupid' => (int) $other['bookinggroupid'], + ]; + + course_group_helper::remove_booked_students($event->get_slot(), $appointment); + } +} diff --git a/db/events.php b/db/events.php new file mode 100644 index 00000000..a0cc0534 --- /dev/null +++ b/db/events.php @@ -0,0 +1,40 @@ +. + +/** + * Event listeners for mod_scheduler. + * + * @package mod_scheduler + * @copyright 2017 Henning Bostelmann and others (see README.txt) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$observers = [ + [ + 'eventname' => '\mod_scheduler\event\slot_added', + 'callback' => '\mod_scheduler\observer::slot_added', + ], + [ + 'eventname' => '\mod_scheduler\event\booking_added', + 'callback' => '\mod_scheduler\observer::booking_added', + ], + [ + 'eventname' => '\mod_scheduler\event\booking_removed', + 'callback' => '\mod_scheduler\observer::booking_removed', + ], +]; diff --git a/db/install.xml b/db/install.xml index 5df0139f..51a6e2c8 100644 --- a/db/install.xml +++ b/db/install.xml @@ -16,11 +16,14 @@ + + + @@ -44,6 +47,7 @@ + @@ -51,6 +55,7 @@ + @@ -64,6 +69,7 @@ + @@ -84,4 +90,4 @@ - \ No newline at end of file + diff --git a/db/upgrade.php b/db/upgrade.php index fc82aeca..6d4da9a8 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -416,5 +416,92 @@ function xmldb_scheduler_upgrade($oldversion = 0) { upgrade_mod_savepoint(true, 2022120200, 'scheduler'); } + /* ******************* 4.2 upgrade line ********************** */ + if ($oldversion < 2023052300) { + upgrade_mod_savepoint(true, 2023052300, 'scheduler'); + } + + if ($oldversion < 2024080103) { + upgrade_mod_savepoint(true, 2024080103, 'scheduler'); + } + + if ($oldversion < 2026092200) { + $table = new xmldb_table('scheduler_appointment'); + $field = new xmldb_field( + 'bookinggroupid', + XMLDB_TYPE_INTEGER, + '11', + null, + XMLDB_NOTNULL, + null, + '0', + 'studentid' + ); + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + upgrade_mod_savepoint(true, 2026092200, 'scheduler'); + } + + if ($oldversion < 2026100100) { + $table = new xmldb_table('scheduler'); + $field = new xmldb_field('groupcreation', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '0', 'gradingstrategy'); + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + $table = new xmldb_table('scheduler_slots'); + $field = new xmldb_field('coursegroupid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'hideuntil'); + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + upgrade_mod_savepoint(true, 2026100100, 'scheduler'); + } + + if ($oldversion < 2026101500) { + $table = new xmldb_table('scheduler'); + $field = new xmldb_field('bookingtype', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '1', 'bookingrouping'); + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + $groupbookingtype = get_config('mod_scheduler', 'mixindivgroup') ? 1 : 2; + + $DB->set_field_select('scheduler', 'bookingtype', $groupbookingtype, 'bookingrouping >= 0'); + + upgrade_mod_savepoint(true, 2026101500, 'scheduler'); + } + + if ($oldversion < 2026101603) { + $table = new xmldb_table('scheduler_slots'); + $field = new xmldb_field('meetingurl', XMLDB_TYPE_TEXT, null, null, null, null, null, 'appointmentlocation'); + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } else { + $dbman->change_field_type($table, $field); + } + + upgrade_mod_savepoint(true, 2026101603, 'scheduler'); + } + + if ($oldversion < 2026102300) { + $table = new xmldb_table('scheduler'); + $field = new xmldb_field( + 'copyslotsonduplicate', + XMLDB_TYPE_INTEGER, + 1, + null, + XMLDB_NOTNULL, + null, + 0, + 'defaultslotduration' + ); + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + upgrade_mod_savepoint(true, 2026102300, 'scheduler'); + } return true; } diff --git a/index.php b/index.php index e617de2b..c5c296e1 100644 --- a/index.php +++ b/index.php @@ -28,8 +28,13 @@ require_once(dirname(__FILE__) . '/lib.php'); $id = required_param('id', PARAM_INT); // Course id. + $course = $DB->get_record('course', ['id' => $id], '*', MUST_EXIST); +if ($CFG->version > 2025041400) { + \core_courseformat\activityoverviewbase::redirect_to_overview_page($id, 'scheduler'); +} + $PAGE->set_url('/mod/scheduler/index.php', ['id' => $id]); $PAGE->set_pagelayout('incourse'); diff --git a/lang/en/scheduler.php b/lang/en/scheduler.php index 3fa13b7e..e462eb20 100644 --- a/lang/en/scheduler.php +++ b/lang/en/scheduler.php @@ -29,13 +29,23 @@ $string['pluginname'] = 'Scheduler'; $string['pluginadministration'] = 'Scheduler administration'; $string['modulename'] = 'Scheduler'; -$string['modulename_help'] = 'The scheduler activity helps you in scheduling appointments with your students. - -Teachers specify time slots for meetings, students then choose one of them on Moodle. -Teachers in turn can record the outcome of the meeting - and optionally a grade - within the scheduler. - -Group scheduling is supported; that is, each time slot can accommodate several students, and optionally it is possible to schedule appointments for entire groups at the same time.'; +$string['modulename_help'] = '###### Key features +- Teachers can create time slots for appointments +- Students book available slots directly in Moodle +- Supports individual and group scheduling +- Flexible booking modes: "one only" or "one at a time." +- Optional notifications and reminders for appointments +- Teachers can record meeting outcomes and assign grades +- Custom booking forms for messages, file uploads, and CAPTCHA + +###### Ways to use it +- Schedule thesis or project supervision meetings +- Organize regular student consultations or office hours +- Plan group meetings for collaborative work +- Manage open sessions with unlimited participants +- Send reminders or invitations to students who haven’t booked yet'; $string['modulename_link'] = 'mod/scheduler/view'; +$string['modulename_summary'] = 'Facilitates scheduling of appointments between teachers and students with flexible booking options and group support.'; $string['modulenameplural'] = 'Schedulers'; /* ***** Capabilities ****** */ @@ -128,6 +138,9 @@ $string['appointmentnote'] = 'Notes for appointment (visible to student)'; $string['appointments'] = 'Appointments'; $string['appointmentsgrouped'] = 'Appointments grouped by slot'; +$string['appointmentstatus'] = 'Appointment status'; +$string['appointmentstatus_appointed'] = 'Appointment made'; +$string['appointmentstatus_unappointed'] = 'No appointment made'; $string['appointsolo'] = 'just me'; $string['appointsomeone'] = 'Add new appointment'; $string['appointmentsummary'] = 'Appointment on {$a->startdate} from {$a->starttime} to {$a->endtime} with {$a->teacher}'; @@ -193,7 +206,12 @@ $string['confirmrevoke'] = 'Revoke all appointments in the current slot?'; $string['conflictingslots'] = 'The slot on {$a} cannot be created due to conflicting slots:'; $string['copytomyself'] = 'Send a copy to myself'; +$string['copyslotsonduplicate'] = 'Copy slots when duplicating.'; +$string['copyslotsonduplicate_help'] = 'If enabled, duplicating this scheduler activity also copies its existing slots.' +. ' Bookings and personal appointment data are not copied.'; +$string['count_of_total_groups'] = '{$a->count} of {$a->total} groups'; $string['course'] = 'Course'; +$string['coursegrouplocktimeout'] = 'The course group is currently being updated. Please try again.'; $string['createexport'] = 'Create export file'; $string['csvformat'] = 'CSV'; $string['csvfieldseparator'] = 'Field separator for CSV'; @@ -284,9 +302,21 @@ The gradebook can show either
  • the mean grade or
  • the maximum grade
that the student has achieved.'; $string['group'] = 'group '; $string['groupbreakdown'] = 'By group size'; +$string['bookingtype'] = 'Allowed booking types'; +$string['bookingtype_help'] = 'Choose whether students can book both individually and for a group, or only for a group. +If only group bookings are allowed, students must select one of their eligible groups. The "Myself" option is not available.'; +$string['bookingtypeboth'] = 'Individual and group bookings'; +$string['bookingtypegroup'] = 'Group bookings only'; +$string['groupcreationrequiresindividual'] = 'Automatic group creation requires individual bookings to be allowed. Select "Never" or allow individual bookings.'; $string['groupbookings'] = 'Booking in groups'; $string['groupbookings_help'] = 'Allow students to book a slot for all members of their group. (Note that this is separate from the "group mode" setting, which controls the slots a student can see.)'; +$string['groupcreation_help'] = 'Course groups will be automatically created and updated.
  • \'Group creation per empty slot\': A course group is created for every new slot.
  • +
  • \'Group creation per booked slot\': A course group is created for every booked slot.
'; +$string['groupcreation'] = 'Create course groups automatically'; +$string['nogroupcreation'] = 'Never'; +$string['groupcreationforslot'] = 'When slot is created'; +$string['groupcreationforbooking'] = 'When a slot is first booked individually'; $string['groupmodeyourgroups'] = 'Group mode: {$a->groupmode}. Only students in {$a->grouplist} can book appointments with you.'; $string['groupmodeyourgroupsempty'] = 'Group mode: {$a->groupmode}. You are not member of any group, therefore students cannot book appointments with you.'; $string['groupscheduling'] = 'Enable group scheduling'; @@ -304,14 +334,21 @@ $string['includeemptyslots'] = 'Include empty slots'; $string['includeslotsfor'] = 'Include slots for'; $string['incourse'] = ' in course '; -$string['mixindivgroup'] = 'Mix individual and group bookings'; -$string['mixindivgroup_desc'] = 'Where group scheduling is enabled, allow individual bookings as well.'; +$string['keepattendedincalendar'] = 'Keep attended appointments in calendar'; +$string['keepattendedincalendar_desc'] = 'If enabled, appointments remain visible in participants’ calendars after they have been marked as attended. If disabled, attended appointments are removed from the calendar.'; +$string['mixindivgroup'] = 'Allow individual bookings by default'; +$string['mixindivgroup_desc'] = 'Default for new scheduler activities with group booking enabled. Teachers can change the allowed booking types in each activity.'; $string['introduction'] = 'Introduction'; $string['isnonexclusive'] = 'Non-exclusive'; $string['landscape'] = 'Landscape'; $string['lengthbreakdown'] = 'By slot duration'; $string['limited'] = 'Limited ({$a} left)'; $string['location'] = 'Location'; +$string['meetingurl'] = 'Meeting link'; +$string['meetingurl_help'] = 'If you are using an online meeting platform, you can enter the meeting link here.'; +$string['invalidmeetingurl'] = 'Enter a valid link starting with https:// or http://.'; +$string['meetingurltoolong'] = 'The meeting link must not exceed 2048 characters.'; +$string['joinmeeting'] = 'Join online meeting'; $string['markseen'] = 'After you have had an appointment with a student please mark them as "Seen" by clicking the checkbox near to their user picture above.'; $string['markasseennow'] = 'Mark as seen now'; $string['maxgrade'] = 'Take the highest grade'; @@ -340,6 +377,7 @@ $string['myappointments'] = 'My appointments'; $string['myself'] = 'Myself'; $string['name'] = 'Scheduler name'; +$string['needmakeappointment'] = 'Need to make an appointment'; $string['needteachers'] = 'Slots cannot be added as this course has no teachers'; $string['negativerange'] = 'Range is negative. This can\'t be.'; $string['negativetimerange'] = 'End time must be later than start time.'; diff --git a/lib.php b/lib.php index 50a9782f..3f0328a1 100644 --- a/lib.php +++ b/lib.php @@ -797,3 +797,57 @@ function mod_scheduler_get_fontawesome_icon_map() { 'mod_scheduler:t/no' => 'fa-thumbs-down', ]; } + +/** + * Adds the statistics and export tab to the 'More' menu tab. + * + * @param settings_navigation $settings + * @param navigation_node $schedulernode + * @return void + * @throws \core\exception\moodle_exception + * @throws coding_exception + */ +function scheduler_extend_settings_navigation( + settings_navigation $settings, + navigation_node $schedulernode, +): void { + // Check capabilities. + $cm = $settings->get_page()->cm; + $context = context_module::instance($cm->id); + + $teachercapabilities = [ + 'mod/scheduler:manage', + 'mod/scheduler:manageallappointments', + 'mod/scheduler:canseeotherteachersbooking', + ]; + + if (!has_any_capability($teachercapabilities, $context)) { + return; + } + + // Add tabs to more menu. + $statisticsnode = $schedulernode->add( + get_string('statistics', 'scheduler'), + new moodle_url('/mod/scheduler/view.php', [ + 'id' => $cm->id, + 'what' => 'viewstatistics', + 'subpage' => 'overall', + ]), + navigation_node::TYPE_CUSTOM, + null, + 'schedulerstatistics' + ); + $statisticsnode->set_force_into_more_menu(true); + + $exportnode = $schedulernode->add( + get_string('export', 'scheduler'), + new moodle_url('/mod/scheduler/view.php', [ + 'id' => $cm->id, + 'what' => 'export', + ]), + navigation_node::TYPE_CUSTOM, + null, + 'schedulerexport' + ); + $exportnode->set_force_into_more_menu(true); +} diff --git a/locallib.php b/locallib.php index 4e9a5792..939b482f 100644 --- a/locallib.php +++ b/locallib.php @@ -347,3 +347,83 @@ public function get_parent() { return $this->browser->get_file_info($this->context); } } + +/** + * Creates a course group for the specified slot and scheduler. + * + * This function generates a new course group for the provided slot and scheduler + * if it does not already exist. The group is named based on the scheduler's name, + * slot's start time, and course ID. + * + * @param object $slot The slot object containing the relevant information (e.g., scheduler ID, start time, etc.). + * @return int The ID of the newly created course group. + * + * @throws dml_exception If a database query error occurs. + */ +function scheduler_create_coursegroup($slot) { + global $DB, $CFG; + + require_once($CFG->dirroot . '/group/lib.php'); + + $factory = \core\lock\lock_config::get_lock_factory('mod_scheduler'); + $lock = $factory->get_lock('mod_scheduler_coursegroup_' . $slot->id, 10); + + if (!$lock) { + throw new moodle_exception('coursegrouplocktimeout', 'scheduler'); + } + + try { + $scheduler = $DB->get_record('scheduler', ['id' => $slot->schedulerid], 'name,course', MUST_EXIST); + // Check whether the group already exists. + $groupid = $DB->get_field('scheduler_slots', 'coursegroupid', ['id' => $slot->id], MUST_EXIST); + if ($groupid && $DB->record_exists('groups', ['id' => $groupid, 'courseid' => $scheduler->course])) { + $slot->coursegroupid = $groupid; + return $groupid; + } + // Create group if it does not yet exist. + $group = new stdClass(); + $group->courseid = $scheduler->course; + $group->name = scheduler_create_coursegroupname($scheduler->name, $slot->starttime, $slot->id); + $time = time(); + $group->timecreated = $time; + $group->timemodified = $time; + + $transaction = $DB->start_delegated_transaction(); + try { + $groupid = groups_create_group($group); + $DB->set_field('scheduler_slots', 'coursegroupid', $groupid, ['id' => $slot->id]); + $transaction->allow_commit(); + } catch (\Throwable $e) { + $transaction->rollback($e); + } + + $slot->coursegroupid = $groupid; + + return $groupid; + } finally { + $lock->release(); + } +} + +/** + * Creates a unique name for a course group based on the provided slot and course data. + * + * This function constructs a name using the scheduler's name, slot's start time, and course ID, + * ensuring uniqueness by appending an incremented identifier if other groups share + * a similar name pattern. + * + * @param string $name Scheduler name. + * @param int $time The slot start time. + * @param int $slotid The slot ID. + * @return string The generated course group name. + * @throws coding_exception + */ +function scheduler_create_coursegroupname($name, $time, $slotid) { + $timezone = core_date::get_server_timezone(); + $datetime = userdate($time, '%Y-%m-%d %H:%M', $timezone, false); + $suffix = ' - ' . $datetime . ' ' . get_string('slot', 'scheduler') . ' ' . $slotid; + $maxlength = 254; + $namelength = max(0, $maxlength - core_text::strlen($suffix)); + $shortname = core_text::substr($name, 0, $namelength); + return $shortname . $suffix; +} diff --git a/mod_form.php b/mod_form.php index d2ac522d..2a25b94c 100644 --- a/mod_form.php +++ b/mod_form.php @@ -92,6 +92,7 @@ public function definition() { $mform->addGroup($modegroup, 'modegrp', get_string('mode', 'scheduler'), ' ', false); $mform->addHelpButton('modegrp', 'appointmentmode', 'scheduler'); + // Booking in groups. if (get_config('mod_scheduler', 'groupscheduling')) { $selopt = [ -1 => get_string('no'), @@ -105,8 +106,29 @@ public function definition() { $mform->addElement('select', 'bookingrouping', get_string('groupbookings', 'scheduler'), $selopt); $mform->addHelpButton('bookingrouping', 'groupbookings', 'scheduler'); $mform->setDefault('bookingrouping', '-1'); + + // Allowed booking types when group booking is enabled. + $bookingtypes = [ + 1 => get_string('bookingtypeboth', 'scheduler'), + 2 => get_string('bookingtypegroup', 'scheduler'), + ]; + + $mform->addElement('select', 'bookingtype', get_string('bookingtype', 'scheduler'), $bookingtypes); + $mform->addHelpButton('bookingtype', 'bookingtype', 'scheduler'); + $mform->setDefault('bookingtype', get_config('mod_scheduler', 'mixindivgroup') ? 1 : 2); + $mform->hideIf('bookingtype', 'bookingrouping', 'eq', '-1'); } + // Automatic course group creation. + $mform->addElement( + 'select', + 'groupcreation', + get_string('groupcreation', 'scheduler'), + $this->get_group_creation_options() + ); + $mform->addHelpButton('groupcreation', 'groupcreation', 'scheduler'); + $mform->setDefault('groupcreation', '0'); + $mform->addElement('duration', 'guardtime', get_string('guardtime', 'scheduler'), ['optional' => true]); $mform->addHelpButton('guardtime', 'guardtime', 'scheduler'); @@ -125,6 +147,11 @@ public function definition() { $mform->addElement('select', 'usenotes', get_string('usenotes', 'scheduler'), $noteoptions); $mform->setDefault('usenotes', '1'); + $mform->addElement('selectyesno', 'copyslotsonduplicate', get_string('copyslotsonduplicate', 'scheduler')); + $mform->setType('copyslotsonduplicate', PARAM_INT); + $mform->setDefault('copyslotsonduplicate', 0); + $mform->addHelpButton('copyslotsonduplicate', 'copyslotsonduplicate', 'scheduler'); + // Grade settings. $this->standard_grading_coursemodule_elements(); @@ -261,4 +288,43 @@ public function save_mod_data(stdClass $data, context_module $context) { $DB->update_record('scheduler', $data); } } + + /** + * Validation to not allow contradictory group slot settings. When only group bookings are allowed, automatic group + * creation should never happen. + * + * @param array $data Submitted form values. + * @param array $files Submitted files. + * @return array + * @throws coding_exception + * @throws dml_exception + */ + public function validation($data, $files) { + $errors = parent::validation($data, $files); + + $grouponly = get_config('mod_scheduler', 'groupscheduling') + && ($data['bookingrouping'] ?? -1) >= 0 + && (int) ($data['bookingtype'] ?? 1) === 2; + + if ($grouponly && (int) ($data['groupcreation'] ?? 0) !== 0) { + $errors['groupcreation'] = + get_string('groupcreationrequiresindividual', 'scheduler'); + } + + return $errors; + } + + /** + * Retrieves the available scheduler group modes for the automatic group creation from a slot. + * + * @return array + * @throws coding_exception + */ + private function get_group_creation_options() { + $groupcreationoptions = []; + $groupcreationoptions[0] = get_string('nogroupcreation', 'scheduler'); + $groupcreationoptions[1] = get_string('groupcreationforslot', 'scheduler'); + $groupcreationoptions[2] = get_string('groupcreationforbooking', 'scheduler'); + return $groupcreationoptions; + } } diff --git a/pix/monologo.svg b/pix/monologo.svg index 1c3b194d..76ca1fba 100644 --- a/pix/monologo.svg +++ b/pix/monologo.svg @@ -1 +1 @@ - + diff --git a/renderable.php b/renderable.php index 951f9e2f..09b3a8a1 100644 --- a/renderable.php +++ b/renderable.php @@ -102,6 +102,7 @@ public function add_slot( $slot->endtime = $slotmodel->endtime; $slot->attended = $appointmentmodel->attended; $slot->location = $slotmodel->appointmentlocation; + $slot->meetingurl = $slotmodel->meetingurl; $slot->slotnote = $slotmodel->notes; $slot->slotnoteformat = $slotmodel->notesformat; $slot->teacher = $slotmodel->get_teacher(); @@ -282,6 +283,7 @@ public function add_slot(slot $slotmodel, $canbook, $bookedbyme, $groupinfo, $ot $slot->starttime = $slotmodel->starttime; $slot->endtime = $slotmodel->endtime; $slot->location = $slotmodel->appointmentlocation; + $slot->meetingurl = $bookedbyme ? $slotmodel->meetingurl : ''; $slot->notes = $slotmodel->notes; $slot->notesformat = $slotmodel->notesformat; $slot->bookedbyme = $bookedbyme; @@ -445,6 +447,7 @@ public function add_slot(slot $slotmodel, scheduler_student_list $students, $edi $slot->starttime = $slotmodel->starttime; $slot->endtime = $slotmodel->endtime; $slot->location = $slotmodel->appointmentlocation; + $slot->meetingurl = $slotmodel->meetingurl; $slot->teacher = $slotmodel->get_teacher(); $slot->students = $students; $slot->editable = $editable; @@ -667,11 +670,6 @@ class scheduler_appointment_info implements renderable */ public $showresult; - /** - * @var bool whether to show booking info - */ - public $showboookinginfo; - /** * Create appointment information for a new appointment in a slot. * diff --git a/renderer.php b/renderer.php index f53bbc6c..70b14d18 100644 --- a/renderer.php +++ b/renderer.php @@ -301,7 +301,7 @@ public function appointment_link($scheduler, $user, $appointmentid) { 'appointmentid' => $appointmentid, ]; $url = new moodle_url('/mod/scheduler/view.php', $paras); - return html_writer::link($url, fullname($user)); + return html_writer::link($url, fullname($user), ['class' => 'align-middle']); } /** @@ -395,6 +395,15 @@ public function teacherview_tabs( $this->teacherview_tab($baseurl, 'groupbreakdown', 'viewstatistics', 'groupbreakdown'), ]; + $statisticstabs = $statstab->subtree; + $statisticsids = array_map(function ($tab) { + return $tab->id; + }, $statisticstabs); + + if (in_array($selected, $statisticsids, true)) { + return $this->tabtree($statisticstabs, $selected, $inactive); + } + $level1 = []; $level1[] = $this->teacherview_tab($baseurl, 'myappointments', 'view', 'myappointments'); if ($permissions->can_see_all_slots()) { @@ -402,8 +411,6 @@ public function teacherview_tabs( } $level1[] = $this->teacherview_tab($baseurl, 'datelist', 'datelist'); - $level1[] = $statstab; - $level1[] = $this->teacherview_tab($baseurl, 'export', 'export'); return $this->tabtree($level1, $selected, $inactive); } @@ -416,6 +423,7 @@ public function teacherview_tabs( */ public function render_scheduler_slot_table(scheduler_slot_table $slottable) { $table = new html_table(); + $table->attributes['class'] = 'table generaltable table-reboot table-hover table-striped'; if ($slottable->showslot) { $table->head = [get_string('date', 'scheduler')]; @@ -501,7 +509,22 @@ public function render_scheduler_slot_table(scheduler_slot_table $slottable) { } if ($slottable->showslot && $slottable->showlocation) { - $rowdata[] = format_string($slot->location); + $location = format_string($slot->location); + if (!empty($slot->meetingurl)) { + $location .= html_writer::div( + html_writer::link( + new moodle_url($slot->meetingurl), + get_string('joinmeeting', 'scheduler'), + [ + 'class' => 'btn btn-secondary', + 'target' => '_blank', + 'rel' => 'noopener noreferrer', + ] + ), + 'mt-2' + ); + } + $rowdata[] = $location; } $notes = ''; @@ -610,7 +633,7 @@ public function render_scheduler_student_list(scheduler_student_list $studentlis $student->entryid, $student->checked, '', - ['class' => 'studentselect'] + ['class' => 'studentselect form-check-input align-middle mt-0'] ); } else { $img = $student->checked ? 'ticked' : 'unticked'; @@ -645,13 +668,16 @@ public function render_scheduler_student_list(scheduler_student_list $studentlis $class .= ' highlight'; } - $picture = $this->user_picture($student->user, ['courseid' => $studentlist->scheduler->courseid]); + $picture = $this->user_picture( + $student->user, + ['courseid' => $studentlist->scheduler->courseid, 'class' => 'userpicture ms-1'] + ); $grade = ''; if ($studentlist->showgrades && $student->grade) { $grade = $this->format_grade($studentlist->scheduler, $student->grade, true); } - $o .= html_writer::div($checkbox . $picture . ' ' . $name . $studicons . ' ' . $grade, $class); + $o .= html_writer::div($checkbox . $picture . $name . $studicons . ' ' . $grade, $class); } if ($editable) { @@ -678,6 +704,7 @@ public function render_scheduler_student_list(scheduler_student_list $studentlis public function render_scheduler_slot_booker(scheduler_slot_booker $booker) { $table = new html_table(); + $table->attributes['class'] = 'table generaltable table-reboot table-hover table-striped'; $table->head = [ get_string('date', 'scheduler'), get_string('start', 'scheduler'), get_string('end', 'scheduler'), get_string('location', 'scheduler'), get_string('comments', 'scheduler'), s($booker->scheduler->get_teacher_name()), @@ -719,7 +746,22 @@ public function render_scheduler_slot_booker(scheduler_slot_booker $booker) { $rowdata[] = $starttimestr; $rowdata[] = $endtimestr; - $rowdata[] = format_string($slot->location); + $location = format_string($slot->location); + if (!empty($slot->meetingurl)) { + $location .= html_writer::div( + html_writer::link( + new moodle_url($slot->meetingurl), + get_string('joinmeeting', 'scheduler'), + [ + 'class' => 'btn btn-secondary', + 'target' => '_blank', + 'rel' => 'noopener noreferrer', + ] + ), + 'mt-2' + ); + } + $rowdata[] = $location; $rowdata[] = $this->format_notes( $slot->notes, @@ -834,6 +876,7 @@ public function render_scheduler_slot_manager(scheduler_slot_manager $slotman) { $table->align[] = 'center'; $table->id = 'slotmanager'; + $table->attributes['class'] = 'table generaltable table-reboot table-hover table-striped'; $table->data = []; $previousdate = ''; @@ -843,7 +886,13 @@ public function render_scheduler_slot_manager(scheduler_slot_manager $slotman) { foreach ($slotman->slots as $slot) { $rowdata = []; - $selectbox = html_writer::checkbox('selectedslot[]', $slot->slotid, false, '', ['class' => 'slotselect']); + $selectbox = html_writer::checkbox( + 'selectedslot[]', + $slot->slotid, + false, + '', + ['class' => 'slotselect form-check-input mt-0'] + ); $rowdata[] = $slot->editable ? $selectbox : ''; $startdate = $this->userdate($slot->starttime); @@ -871,7 +920,22 @@ public function render_scheduler_slot_manager(scheduler_slot_manager $slotman) { $rowdata[] = $starttimestr; $rowdata[] = $endtimestr; - $rowdata[] = format_string($slot->location); + $location = format_string($slot->location); + if (!empty($slot->meetingurl)) { + $location .= html_writer::div( + html_writer::link( + new moodle_url($slot->meetingurl), + get_string('joinmeeting', 'scheduler'), + [ + 'class' => 'btn btn-secondary', + 'target' => '_blank', + 'rel' => 'noopener noreferrer', + ] + ), + 'mt-2' + ); + } + $rowdata[] = $location; $rowdata[] = $this->render($slot->students); @@ -963,6 +1027,7 @@ public function render_scheduler_slot_manager(scheduler_slot_manager $slotman) { public function render_scheduler_scheduling_list(scheduler_scheduling_list $list) { $mtable = new html_table(); + $mtable->attributes['class'] = 'table generaltable table-reboot table-hover table-striped'; $mtable->id = $list->id; $mtable->head = ['', get_string('name')]; @@ -1112,6 +1177,24 @@ public function render_scheduler_appointment_info(scheduler_appointment_info $ai $t->data[] = $row; } + global $USER; + $canjoinmeeting = !$ai->onstudentside || $ai->slot->is_booked_by_student($USER->id); + if ($canjoinmeeting && !empty($ai->slot->meetingurl)) { + $row = new html_table_row(); + $cell1 = new html_table_cell(get_string('meetingurl', 'scheduler')); + $cell2 = new html_table_cell(html_writer::link( + new moodle_url($ai->slot->meetingurl), + get_string('joinmeeting', 'scheduler'), + [ + 'class' => 'btn btn-secondary', + 'target' => '_blank', + 'rel' => 'noopener noreferrer', + ] + )); + $row->cells = [$cell1, $cell2]; + $t->data[] = $row; + } + if ($ai->slot->notes) { $row = new html_table_row(); $cell1 = new html_table_cell(get_string('comments', 'scheduler')); diff --git a/settings.php b/settings.php index 21bfd8fe..3edf27f7 100644 --- a/settings.php +++ b/settings.php @@ -70,4 +70,11 @@ get_string('revealteachernotes_desc', 'scheduler'), 0 )); + + $settings->add(new admin_setting_configcheckbox( + 'mod_scheduler/keepattendedincalendar', + get_string('keepattendedincalendar', 'scheduler'), + get_string('keepattendedincalendar_desc', 'scheduler'), + 0 + )); } diff --git a/slotforms.php b/slotforms.php index 1f3a1377..366ca334 100644 --- a/slotforms.php +++ b/slotforms.php @@ -24,6 +24,7 @@ defined('MOODLE_INTERNAL') || die(); +use mod_scheduler\course_group_helper; use mod_scheduler\model\scheduler; use mod_scheduler\model\slot; @@ -114,6 +115,11 @@ protected function add_base_fields() { $mform->setDefault('appointmentlocation', $this->scheduler->get_last_location($USER)); $mform->addHelpButton('appointmentlocation', 'location', 'scheduler'); + // Optional meeting link. + $mform->addElement('text', 'meetingurl', get_string('meetingurl', 'scheduler'), ['size' => '60']); + $mform->setType('meetingurl', PARAM_RAW_TRIMMED); + $mform->addHelpButton('meetingurl', 'meetingurl', 'scheduler'); + // Choose the teacher (if allowed). if (has_capability('mod/scheduler:canscheduletootherteachers', $this->scheduler->get_context())) { $teachername = s($this->scheduler->get_teacher_name()); @@ -176,6 +182,18 @@ protected function add_duration_field($minuteslabel = 'minutes') { */ public function validation($data, $files) { $errors = parent::validation($data, $files); + $meetingurl = $data['meetingurl'] ?? ''; + + if ($meetingurl != '') { + if (\core_text::strlen($meetingurl) > 2048) { + $errors['meetingurl'] = get_string('meetingurltoolong', 'scheduler'); + } else if ( + !preg_match('~^https?://~i', $meetingurl) || + clean_param($meetingurl, PARAM_URL) !== $meetingurl + ) { + $errors['meetingurl'] = get_string('invalidmeetingurl', 'scheduler'); + } + } // Check duration for valid range. if ($this->hasduration) { @@ -260,6 +278,10 @@ protected function definition() { ); $mform->setType('notes', PARAM_RAW); // Must be PARAM_RAW for rich text editor content. + // Group ID. + $mform->addElement('hidden', 'bookinggroupid', 0); + $mform->setType('bookinggroupid', PARAM_INT); + // Appointments. $repeatarray = []; @@ -510,6 +532,7 @@ public function save_slot($slotid, $data) { $slot->exclusivity = $data->exclusivityenable ? $data->exclusivity : 0; $slot->teacherid = $data->teacherid; $slot->appointmentlocation = $data->appointmentlocation; + $slot->meetingurl = $data->meetingurl; $slot->hideuntil = $data->hideuntil; $slot->emaildate = $data->emaildate; $slot->timemodified = time(); @@ -531,10 +554,12 @@ public function save_slot($slotid, $data) { $slot->notesformat = $editor['format']; $currentapps = $slot->get_appointments(); + $removedappointments = []; for ($i = 0; $i < $data->appointment_repeats; $i++) { if ($data->deletestudent[$i] != 0) { if ($data->appointid[$i]) { $app = $slot->get_appointment($data->appointid[$i]); + $removedappointments[] = $app->get_data(); $slot->remove_appointment($app); } } else if ($data->studentid[$i] > 0) { @@ -544,6 +569,7 @@ public function save_slot($slotid, $data) { } else { $app = $slot->create_appointment(); $app->studentid = $data->studentid[$i]; + $app->bookinggroupid = $data->bookinggroupid ?? 0; $app->timecreated = time(); $app->save(); } @@ -587,6 +613,14 @@ public function save_slot($slotid, $data) { $slot->save(); + // Remove students for canceled appointments. + foreach ($removedappointments as $appointment) { + course_group_helper::remove_booked_students($slot, $appointment); + } + + // Ensure all currently booked students belong to the course group. + course_group_helper::add_booked_students($slot); + $slot = $this->scheduler->get_slot($slot->id); return $slot; @@ -715,6 +749,16 @@ protected function definition() { $mform->addElement('select', 'emaildaterel', get_string('emailreminder', 'scheduler'), $remindersel); $mform->setDefault('remindersel', -1); + // Slot comments. + $mform->addElement( + 'editor', + 'notes_editor', + get_string('comments', 'scheduler'), + ['rows' => 3, 'columns' => 60], + $this->noteoptions + ); + $mform->setType('notes', PARAM_RAW); // Must be PARAM_RAW for rich text editor content. + $this->add_action_buttons(); } diff --git a/studentview.controller.php b/studentview.controller.php index 2cab25e7..aab2b455 100644 --- a/studentview.controller.php +++ b/studentview.controller.php @@ -72,7 +72,17 @@ function scheduler_book_slot($scheduler, $slotid, $userid, $groupid, $mform, $fo $errormessage = ''; - $bookinglimit = $scheduler->count_bookable_appointments($userid, false); + if ($groupid > 0) { + $groupalreadybooked = $scheduler->has_slots_booked_for_group( + $groupid, + false, + $scheduler->schedulermode === 'onetime' + ); + $bookinglimit = $groupalreadybooked ? 0 : 1; + } else { + $bookinglimit = $scheduler->count_bookable_appointments($userid, false); + } + if ($bookinglimit == 0) { $errormessage = get_string('selectedtoomany', 'scheduler', $bookinglimit); } else { @@ -104,6 +114,7 @@ function scheduler_book_slot($scheduler, $slotid, $userid, $groupid, $mform, $fo foreach ($userstobook as $studentid) { $appointment = $slot->create_appointment(); $appointment->studentid = $studentid; + $appointment->bookinggroupid = max($groupid, 0); $appointment->attended = 0; $appointment->timecreated = time(); $appointment->timemodified = time(); @@ -113,6 +124,11 @@ function scheduler_book_slot($scheduler, $slotid, $userid, $groupid, $mform, $fo $mform->save_booking_data($formdata, $appointment); } + // A newly created booking cannot already have been attended. + $appointment->attended = 0; + $appointment->timemodified = time(); + $appointment->save(); + \mod_scheduler\event\booking_added::create_from_slot($slot)->trigger(); // Notify the teacher. @@ -289,33 +305,42 @@ function scheduler_book_slot($scheduler, $slotid, $userid, $groupid, $mform, $fo throw new moodle_exception('nopermissions'); } - $userstocancel = [$USER->id]; - if ($appointgroup) { - $userstocancel = array_keys($scheduler->get_available_students($appointgroup)); - } + $appointmentstocancel = []; - foreach ($userstocancel as $userid) { - if ($appointment = $slot->get_student_appointment($userid)) { - $scheduler->delete_appointment($appointment->id); - - // Notify the teacher. - if ($scheduler->allownotifications) { - $student = $DB->get_record('user', ['id' => $USER->id]); - $teacher = $DB->get_record('user', ['id' => $slot->teacherid]); - scheduler_messenger::send_slot_notification( - $slot, - 'bookingnotification', - 'cancelled', - $student, - $teacher, - $teacher, - $student, - $COURSE - ); + if ($appointgroup > 0) { + foreach ($slot->get_appointments() as $appointment) { + if ((int) $appointment->bookinggroupid === $appointgroup) { + $appointmentstocancel[] = $appointment; } + } + } else { + $appointment = $slot->get_student_appointment($USER->id); + if ($appointment && (int) $appointment->bookinggroupid === 0) { + $appointmentstocancel[] = $appointment; + } + } - \mod_scheduler\event\booking_removed::create_from_slot($slot)->trigger(); + foreach ($appointmentstocancel as $appointment) { + $appointmentdata = $appointment->get_data(); + $scheduler->delete_appointment($appointment->id); + + // Notify the teacher. + if ($scheduler->allownotifications) { + $student = $DB->get_record('user', ['id' => $USER->id]); + $teacher = $DB->get_record('user', ['id' => $slot->teacherid]); + scheduler_messenger::send_slot_notification( + $slot, + 'bookingnotification', + 'cancelled', + $student, + $teacher, + $teacher, + $student, + $COURSE + ); } + + \mod_scheduler\event\booking_removed::create_from_slot($slot, $appointmentdata)->trigger(); } redirect($returnurl); diff --git a/studentview.php b/studentview.php index 8721c44f..f2856198 100644 --- a/studentview.php +++ b/studentview.php @@ -173,7 +173,17 @@ echo $output->render($slottable); } -$bookablecnt = $scheduler->count_bookable_appointments($USER->id, false); +if ($appointgroup > 0) { + $groupalreadybooked = $scheduler->has_slots_booked_for_group( + $appointgroup, + false, + $scheduler->schedulermode === 'onetime' + ); + $bookablecnt = $groupalreadybooked ? 0 : 1; +} else { + $bookablecnt = $scheduler->count_bookable_appointments($USER->id, false); +} + $bookableslots = array_values($scheduler->get_slots_available_to_student($USER->id, $canseefull)); if (!$canseefull && $bookablecnt == 0) { diff --git a/styles.css b/styles.css index e5cfe623..308bb2b7 100644 --- a/styles.css +++ b/styles.css @@ -36,6 +36,12 @@ div .path-mod-scheduler .appointmentnote { margin-right: auto; } +.path-mod-scheduler #slotmanager td, +.path-mod-scheduler #studentstoschedule td, +.path-mod-scheduler #groupstoschedule td { + vertical-align: middle; +} + .path-mod-scheduler div.bookercontrols { text-align: center; } @@ -96,17 +102,21 @@ div .path-mod-scheduler .appointmentnote { } -body.path-mod-scheduler input.slotselect { +body.path-mod-scheduler input.slotselect.form-check-input.mt-0 { display: none; } -body.path-mod-scheduler.jsenabled input.slotselect { - display: inline; +body.path-mod-scheduler.jsenabled input.slotselect.form-check-input.mt-0 { + display: block; } body.path-mod-scheduler.jsenabled input.studentselectsubmit { display: none; } +.path-mod-scheduler .studentlist input.studentselect { + margin-inline-end: 0.5rem; +} + .path-mod-scheduler img.statictickbox { padding-right: 5px; } @@ -119,13 +129,12 @@ body.path-mod-scheduler.jsenabled input.studentselectsubmit { } .path-mod-scheduler div.schedulelist.halfsize { - width: 46%; + width: 50%; display: inline-table; padding: 3px; } .path-mod-scheduler div.schedulelist.fullsize { - width: 96%; display: block; padding: 3px; } diff --git a/teacherview.controller.php b/teacherview.controller.php index affe6016..75ecc65f 100644 --- a/teacherview.controller.php +++ b/teacherview.controller.php @@ -50,6 +50,7 @@ function scheduler_action_doaddsession($scheduler, $formdata, moodle_url $return $slot->schedulerid = $scheduler->id; $slot->teacherid = $data->teacherid; $slot->appointmentlocation = $data->appointmentlocation; + $slot->meetingurl = $data->meetingurl; $slot->exclusivity = $data->exclusivityenable ? $data->exclusivity : 0; if ($data->divide) { $slot->duration = $data->duration; @@ -57,8 +58,17 @@ function scheduler_action_doaddsession($scheduler, $formdata, moodle_url $return $slot->duration = max(1, $data->endhour * 60 + $data->endminute - $data->starthour * 60 - $data->startminute); } + $editor = $data->notes_editor; + $context = $scheduler->get_context(); + $noteoptions = [ + 'trusttext' => true, + 'maxfiles' => -1, + 'maxbytes' => 0, + 'context' => $context, + 'subdirs' => false, + ]; $slot->notes = ''; - $slot->notesformat = FORMAT_HTML; + $slot->notesformat = $editor['format']; $slot->timemodified = time(); for ($d = 0; $d <= $fordays; $d++) { @@ -149,6 +159,16 @@ function scheduler_action_doaddsession($scheduler, $formdata, moodle_url $return if (!$conflicts || $resolvable) { $slotid = $DB->insert_record('scheduler_slots', $slot, true, true); + $notes = file_save_draft_area_files( + $editor['itemid'], + $context->id, + 'mod_scheduler', + 'slotnote', + $slotid, + $noteoptions, + $editor['text'] + ); + $DB->set_field('scheduler_slots', 'notes', $notes, ['id' => $slotid]); $slotobj = $scheduler->get_slot($slotid); \mod_scheduler\event\slot_added::create_from_slot($slotobj)->trigger(); $countslots++; @@ -299,8 +319,10 @@ function scheduler_action_delete_slots(array $slots, $action, moodle_url $return $permissions->ensure($permissions->can_edit_slot($slot)); $oldstudents = []; + $removedappointments = []; foreach ($slot->get_appointments() as $app) { $oldstudents[] = $app->studentid; + $removedappointments[] = $app->get_data(); $slot->remove_appointment($app); } @@ -326,6 +348,11 @@ function scheduler_action_delete_slots(array $slots, $action, moodle_url $return } $slot->save(); + + foreach ($removedappointments as $appointment) { + \mod_scheduler\course_group_helper::remove_booked_students($slot, $appointment); + } + redirect($viewurl); break; diff --git a/teacherview.php b/teacherview.php index 556fdaf3..7df073f8 100644 --- a/teacherview.php +++ b/teacherview.php @@ -322,6 +322,7 @@ function scheduler_print_schedulebox(scheduler $scheduler, $studentid, $groupid ['slotid' => $slotid, 'repeats' => $repeats] ); $data = $mform->prepare_formdata($slot); + $data->bookinggroupid = $groupid; foreach ($members as $member) { $data->studentid[] = $member->id; } @@ -333,7 +334,9 @@ function scheduler_print_schedulebox(scheduler $scheduler, $studentid, $groupid } else if (empty($subaction)) { $actionurl = new moodle_url($baseurl, ['what' => 'addslot']); - $data = []; + $data = [ + 'bookinggroupid' => $groupid, + ]; $i = 0; foreach ($members as $member) { $data['studentid'][$i] = $member->id; @@ -621,7 +624,7 @@ function scheduler_print_schedulebox(scheduler $scheduler, $studentid, $groupid $maildisplay .= ' — '; $maildisplay .= html_writer::link($reminderurl, get_string('sendreminder', 'scheduler')); - echo $output->box_start('maildisplay'); + echo $output->box_start('maildisplay my-3'); // Print number of students who still have to make an appointment. echo $output->heading(get_string('missingstudents', 'scheduler', count($reminderstudents)), 3); // Print e-mail addresses and mailto links. diff --git a/templates/action_menu_trigger.mustache b/templates/action_menu_trigger.mustache index 98b6029a..8e9bcbf9 100644 --- a/templates/action_menu_trigger.mustache +++ b/templates/action_menu_trigger.mustache @@ -87,7 +87,7 @@ . + +namespace mod_scheduler; + +/** + * Unit tests for scheduler activity duplication. + * + * @package mod_scheduler + * @copyright 2011 Henning Bostelmann and others (see README.txt) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class backup_restore_test extends \advanced_testcase { + /** + * Load the backup and restore infrastructure. + */ + public static function setUpBeforeClass(): void { + global $CFG; + + parent::setUpBeforeClass(); + + require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); + require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); + require_once($CFG->dirroot . '/course/lib.php'); + } + + /** + * Tests that duplication preserves slot details, teacher assignments and group + * restrictions without copying bookings or modifying the original activity. + * + * @return void + * @throws \coding_exception + * @throws \core\exception\coding_exception + * @throws \core\exception\moodle_exception + * @throws \dml_exception + * + * @covers \backup_scheduler_activity_structure_step + * @covers \restore_scheduler_activity_structure_step + */ + public function test_duplicate_with_copying_slots(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + $student = $generator->create_user(); + $teacher = $generator->create_user(); + + $generator->enrol_user($teacher->id, $course->id, 'editingteacher'); + $generator->enrol_user($student->id, $course->id, 'student'); + + $group = $generator->create_group(['courseid' => $course->id]); + $generator->create_group_member([ + 'groupid' => $group->id, + 'userid' => $student->id, + ]); + + // Create a scheduler with one booked slot. + $scheduler = $generator->create_module( + 'scheduler', + ['course' => $course->id, 'copyslotsonduplicate' => 1], + ['slottimes' => [time() + DAYSECS], 'slotteachers' => [$teacher->id], 'slotstudents' => [[$student->id]] ] + ); + + $originalslot = $DB->get_record('scheduler_slots', ['schedulerid' => $scheduler->id], '*', MUST_EXIST); + + $DB->set_field('scheduler_slots', 'coursegroupid', $group->id, ['id' => $originalslot->id]); + + $this->assertEquals(1, $DB->count_records('scheduler_appointment', ['slotid' => $originalslot->id])); + + $actions = \core_courseformat\formatactions::cm($course); + + if (method_exists($actions, 'duplicate')) { + $duplicatecm = $actions->duplicate($scheduler->cmid); + } else { + $cm = get_fast_modinfo($course)->get_cm($scheduler->cmid); + $duplicatecm = duplicate_module($course, $cm); + } + + $this->assertNotNull($duplicatecm); + + $duplicateid = (int) $duplicatecm->instance; + + // A separate Scheduler was created. + $this->assertNotEquals($scheduler->id, $duplicateid); + + // The duplicate contains one slot. + $this->assertEquals(1, $DB->count_records('scheduler_slots', ['schedulerid' => $duplicateid])); + + $copiedslot = $DB->get_record('scheduler_slots', ['schedulerid' => $duplicateid], '*', MUST_EXIST); + + // The copied slot belongs to the new Scheduler. + $this->assertEquals($duplicateid, $copiedslot->schedulerid); + + // Teacher assignment and group restriction are preserved. + $this->assertEquals($teacher->id, $copiedslot->teacherid); + $this->assertEquals($group->id, $copiedslot->coursegroupid); + + // The original slot's assignments remain unchanged. + $unchangedslot = $DB->get_record('scheduler_slots', ['id' => $originalslot->id], '*', MUST_EXIST); + + $this->assertEquals($scheduler->id, $unchangedslot->schedulerid); + $this->assertEquals($teacher->id, $unchangedslot->teacherid); + $this->assertEquals($group->id, $unchangedslot->coursegroupid); + + // The original booking remains. + $this->assertEquals(1, $DB->count_records('scheduler_appointment', ['slotid' => $originalslot->id])); + + // The copied slot is a separate record with the same scheduling details. + $this->assertNotEquals($originalslot->id, $copiedslot->id); + $this->assertEquals($originalslot->starttime, $copiedslot->starttime); + $this->assertEquals($originalslot->duration, $copiedslot->duration); + $this->assertEquals($originalslot->teacherid, $copiedslot->teacherid); + $this->assertEquals( + $originalslot->appointmentlocation, + $copiedslot->appointmentlocation + ); + + // The original booking was not copied. + $this->assertEquals(0, $DB->count_records('scheduler_appointment', ['slotid' => $copiedslot->id])); + } +} diff --git a/tests/behat/add_slots.feature b/tests/behat/add_slots.feature index b4ab4952..927f0fc2 100644 --- a/tests/behat/add_slots.feature +++ b/tests/behat/add_slots.feature @@ -98,3 +98,42 @@ Feature: Teacher can add slots to a scheduler activity And I should see "2:00 AM" And I should see "10:00 AM" And I should not see "11:00 AM" + + Scenario: Adding a comment to repeated slots creates a comment for each slot + When I log in as "teacher1" + And I add 3 slots 5 days ahead in "scheduler1" scheduler and I fill the form with: + | Location | Here | + | Comments | This is a comment | + Then I should see "3 slots have been added" + And I should see "1:00 AM" + And I should see "2:00 AM" + And I should see "3:00 AM" + And I log out + + When I am on the "scheduler1" Activity page logged in as "student1" + Then the following should exist in the "slotbookertable" table: + | Start | Location | Comments | + | 1:00 AM | Here | This is a comment | + | 2:00 AM | Here | This is a comment | + | 3:00 AM | Here | This is a comment | + + Scenario: Teacher must enter a valid meeting link + Given I am on the "scheduler1" Activity page logged in as "teacher1" + And I click on "Add slots" "link" + And I follow "Add single slot" + And I set the following fields to these values: + | starttime[day] | 1 | + | starttime[month] | April | + | starttime[year] | 2050 | + | duration | 30 | + | Meeting link | not-a-url | + + When I click on "Save changes" "button" + Then I should see "Enter a valid link starting with https:// or http://." + + When I set the field "Meeting link" to "https://example.com/test-meeting" + And I click on "Save changes" "button" + Then I should see "1 slot added" + And "Join online meeting" "link" should exist + And "a[href='https://example.com/test-meeting']" "css_element" should exist + And I log out diff --git a/tests/behat/behat_mod_scheduler.php b/tests/behat/behat_mod_scheduler.php index 96ca6459..01c5dcba 100644 --- a/tests/behat/behat_mod_scheduler.php +++ b/tests/behat/behat_mod_scheduler.php @@ -42,8 +42,9 @@ class behat_mod_scheduler extends behat_base /** * Adds a series of slots to the scheduler * - * @Given /^I add a slot (\d+) days ahead at (\d+) in "(?P(?:[^"]|\\")*)" scheduler - * and I fill the form with:$/ + * phpcs:disable + * @Given /^I add a slot (\d+) days ahead at (\d+) in "(?P(?:[^"]|\\")*)" scheduler and I fill the form with:$/ + * phpcs:enable * * @param int $daysahead * @param int $time diff --git a/tests/behat/duplicate_slots.feature b/tests/behat/duplicate_slots.feature new file mode 100644 index 00000000..c6138680 --- /dev/null +++ b/tests/behat/duplicate_slots.feature @@ -0,0 +1,54 @@ +@mod @mod_scheduler @duplicateslots +Feature: Optionally copy slots when duplicating a Scheduler + In order to reuse a schedule without copying bookings + As a teacher + I can choose whether duplication includes existing slots + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | One | teacher1@example.com | + | student1 | Student | One | student1@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "activities" exist: + | activity | name | intro | course | idnumber | + | scheduler | Test scheduler | n | C1 | scheduler1 | + And the following "mod_scheduler > slots" exist: + | scheduler | starttime | duration | teacher | location | student | + | scheduler1 | ##+5 days 10am## | 30 | teacher1 | Test office | student1 | + + Scenario: Copy slots without copying bookings + Given I am on the "scheduler1" Activity page logged in as "teacher1" + And I should see "Test office" in the "slotmanager" "table" + And I should see "Student One" in the "slotmanager" "table" + When I navigate to "Settings" in current page administration + And I expand all fieldsets + And I set the field "copyslotsonduplicate" to "Yes" + And I press "Save and display" + And I am on the "C1" course page + And I turn editing mode on + And I duplicate "Test scheduler" activity + And I should see "Test scheduler (copy)" + And I follow "Test scheduler (copy)" + Then I should see "Test office" in the "slotmanager" "table" + And I should not see "Student One" in the "slotmanager" "table" + + Scenario: Duplicate without copying slots + Given I am on the "scheduler1" Activity page logged in as "teacher1" + And I should see "Test office" in the "slotmanager" "table" + When I navigate to "Settings" in current page administration + And I expand all fieldsets + And I set the field "copyslotsonduplicate" to "No" + And I press "Save and display" + And I am on the "C1" course page + And I turn editing mode on + And I duplicate "Test scheduler" activity + And I should see "Test scheduler (copy)" + And I follow "Test scheduler (copy)" + Then "slotmanager" "table" should not exist diff --git a/tests/behat/groupmode.feature b/tests/behat/groupmode.feature index 21049857..b65d88e3 100644 --- a/tests/behat/groupmode.feature +++ b/tests/behat/groupmode.feature @@ -76,7 +76,7 @@ Feature: Users can only see their own groups if the scheduler is in group mode Scenario: Editing teachers can see all slots and all groups When I am on the "schedulerNone" Activity page logged in as "edteacher1" - And I follow "Statistics" + And I follow "Overview" And I follow "All appointments" Then I should see "Editingteacher 1" in the "slotmanager" "table" And I should see "Nonedteacher 1" in the "slotmanager" "table" @@ -86,7 +86,7 @@ Feature: Users can only see their own groups if the scheduler is in group mode And I should see "Student 6" in the "studentstoschedule" "table" When I am on the "schedulerVis" Activity page - And I follow "Statistics" + And I follow "Overview" And I follow "All appointments" Then I should see "Visible groups" And the "Visible groups" select box should contain "All participants" @@ -108,7 +108,7 @@ Feature: Users can only see their own groups if the scheduler is in group mode And I should see "Nonedteacher 1" in the "slotmanager" "table" When I am on the "schedulerSep" Activity page - And I follow "Statistics" + And I follow "Overview" And I follow "All appointments" Then I should see "Separate groups" And the "Separate groups" select box should contain "All participants" @@ -145,7 +145,7 @@ Feature: Users can only see their own groups if the scheduler is in group mode # i.e., groups A and B. # Students outside any group should not be visible. When I am on the "schedulerSep" Activity page - And I follow "Statistics" + And I follow "Overview" And I follow "My appointments" Then I should see "Group mode: Separate groups" And I should see "Only students in Group A, Group B can book" @@ -158,7 +158,7 @@ Feature: Users can only see their own groups if the scheduler is in group mode Scenario: Nonediting teachers can see groups only if allowed by the group mode When I am on the "schedulerNone" Activity page logged in as neteacher1 - And I follow "Statistics" + And I follow "Overview" And I follow "My appointments" Then I should see "6 students still need to make an appointment" When I follow "All appointments" @@ -166,7 +166,7 @@ Feature: Users can only see their own groups if the scheduler is in group mode And I should see "Nonedteacher 1" in the "slotmanager" "table" When I am on the "schedulerVis" Activity page - And I follow "Statistics" + And I follow "Overview" And I follow "My appointments" Then I should see "2 students still need to make an appointment" When I follow "All appointments" @@ -190,7 +190,7 @@ Feature: Users can only see their own groups if the scheduler is in group mode And I should see "Nonedteacher 1" in the "slotmanager" "table" When I am on the "schedulerSep" Activity page - And I follow "Statistics" + And I follow "Overview" And I follow "My appointments" Then I should see "2 students still need to make an appointment" When I follow "All appointments" @@ -218,7 +218,7 @@ Feature: Users can only see their own groups if the scheduler is in group mode # i.e., group B (and C). # Students in group 1 and outside any group should not be visible. When I am on the "schedulerSep" Activity page - And I follow "Statistics" + And I follow "Overview" And I follow "My appointments" Then I should see "Group mode: Separate groups" And I should see "Only students in Group B, Group C can book" @@ -233,19 +233,19 @@ Feature: Users can only see their own groups if the scheduler is in group mode # neteacher2 sees no students for scheduling in group mode, since he's not member of a group When I am on the "schedulerNone" Activity page logged in as neteacher2 - And I follow "Statistics" + And I follow "Overview" And I follow "My appointments" Then I should see "6 students still need to make an appointment" When I am on the "schedulerVis" Activity page - And I follow "Statistics" + And I follow "Overview" And I follow "My appointments" Then I should see "No students available for scheduling" And I should see "Group mode: Visible groups" And I should see "students cannot book appointments with you" When I am on the "schedulerSep" Activity page - And I follow "Statistics" + And I follow "Overview" And I follow "My appointments" Then I should see "No students available for scheduling" And I should see "Group mode: Separate groups" diff --git a/tests/behat/groupscheduling.feature b/tests/behat/groupscheduling.feature index c8737341..2631dab6 100644 --- a/tests/behat/groupscheduling.feature +++ b/tests/behat/groupscheduling.feature @@ -1,4 +1,4 @@ -@mod @mod_scheduler +@mod @mod_scheduler @groupscheduling Feature: Entire groups can be booked into slots at once In order to allow booking of entire groups As a teacher @@ -101,8 +101,8 @@ Feature: Entire groups can be booked into slots at once And I should see "2 students still need to make an appointment" And I should not see "Group A1" in the "groupstoschedule" "table" And I should see "Group A2" in the "groupstoschedule" "table" - And I should not see "Group B1" in the "groupstoschedule" "table" - And I should not see "Group B2" in the "groupstoschedule" "table" + And I should see "Group B1" in the "groupstoschedule" "table" + And I should see "Group B2" in the "groupstoschedule" "table" Scenario: Students can book their entire group into a slot Given I am logged in as edteacher1 @@ -132,6 +132,96 @@ Feature: Entire groups can be booked into slots at once And I should see "2 students still need to make an appointment" And I should not see "Group A1" in the "groupstoschedule" "table" And I should see "Group A2" in the "groupstoschedule" "table" + And I should see "Group B1" in the "groupstoschedule" "table" + And I should see "Group B2" in the "groupstoschedule" "table" + And I log out + + Scenario: Overlapping groups can book separate appointments + Given I am logged in as edteacher1 + And I add a slot 5 days ahead at 800 in "schedulerNone" scheduler and I fill the form with: + | Location | Large office | + | exclusivity | 5 | + And I add a slot 5 days ahead at 900 in "schedulerNone" scheduler and I fill the form with: + | Location | Large office | + | exclusivity | 5 | + And I log out + + When I am on the "schedulerNone" Activity page logged in as student1 + And I select "Group A1" from the "appointgroup" singleselect + And I click on "Book slot" "button" in the "8:00 AM" "table_row" + Then I should see "Large office" in the "8:00 AM" "table_row" + + When I select "Group B1" from the "appointgroup" singleselect + Then "Book slot" "button" should exist in the "9:00 AM" "table_row" + When I click on "Book slot" "button" in the "9:00 AM" "table_row" + Then I should see "Large office" in the "9:00 AM" "table_row" + And I should see "Large office" in the "8:00 AM" "table_row" + And I should see "You cannot book further appointments in this scheduler." + + When I select "Myself" from the "appointgroup" singleselect + Then I should see "You cannot book further appointments in this scheduler." + And "Book slot" "button" should not exist + + When I select "Group A1" from the "appointgroup" singleselect + And I click on "Cancel booking" "button" in the "8:00 AM" "table_row" + And I log out + + When I am on the "schedulerNone" Activity page logged in as edteacher1 + Then I should see "Group A1" in the "groupstoschedule" "table" And I should not see "Group B1" in the "groupstoschedule" "table" - And I should not see "Group B2" in the "groupstoschedule" "table" + And I should not see "Student 1" in the "8:00 AM" "table_row" + And I should not see "Student 2" in the "8:00 AM" "table_row" + And I should see "Student 1" in the "9:00 AM" "table_row" + And I should see "Student 3" in the "9:00 AM" "table_row" + + Scenario: Group bookings turned off should should not show appoint group selection to students + Given I am logged in as edteacher1 + And I am on the "schedulerNone" Activity page logged in as edteacher1 + And I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Booking in groups | No | + And I click on "Save and return to course" "button" + And I add 8 slots 5 days ahead in "schedulerNone" scheduler and I fill the form with: + | Location | Large office | + | exclusivity | 5 | + And I log out + + When I am on the "schedulerNone" Activity page logged in as student1 + Then "select[name='appointgroup']" "css_element" should not exist + And "Book slot" "button" should exist + + Scenario: Group bookings allowed and allowed booking type is "individual and group bookings" should show "Myself" and group selection to students + Given I am logged in as edteacher1 + And I am on the "schedulerNone" Activity page logged in as edteacher1 + And I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Booking in groups | Yes, for all groups | + | Allowed booking type | Individual and group bookings | + And I click on "Save and return to course" "button" + And I add 8 slots 5 days ahead in "schedulerNone" scheduler and I fill the form with: + | Location | Large office | + | exclusivity | 5 | + And I log out + + When I am on the "schedulerNone" Activity page logged in as student1 + Then the "appointgroup" select box should contain "Myself" + And the "appointgroup" select box should contain "Group A1" + And the "appointgroup" select box should contain "Group B1" + + Scenario: Group bookings allowed and allowed booking type is "group bookings only" should show group selection to students but not "Myself" + Given I am logged in as edteacher1 + And I am on the "schedulerNone" Activity page logged in as edteacher1 + And I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Booking in groups | Yes, for all groups | + | Allowed booking type | Group bookings only | + And I click on "Save and return to course" "button" + And I add 8 slots 5 days ahead in "schedulerNone" scheduler and I fill the form with: + | Location | Large office | + | exclusivity | 5 | And I log out + + When I am on the "schedulerNone" Activity page logged in as student1 + Then the "appointgroup" select box should not contain "Myself" + And the "appointgroup" select box should contain "Group A1" + And the "appointgroup" select box should contain "Group B1" diff --git a/tests/behat/notes.feature b/tests/behat/notes.feature index 0c68ddec..f6c812ca 100644 --- a/tests/behat/notes.feature +++ b/tests/behat/notes.feature @@ -29,7 +29,7 @@ Feature: Teachers can write notes on slots and appointments Scenario: Teachers can enter slot notes and appointment notes for others to see When I am on the "scheduler1" Activity page logged in as "edteacher1" - And I follow "Statistics" + And I follow "Overview" And I follow "All appointments" And I click on "Edit" "link" in the "4:00 AM" "table_row" And I set the following fields to these values: @@ -47,7 +47,7 @@ Feature: Teachers can write notes on slots and appointments And I log out When I am on the "scheduler1" Activity page logged in as "edteacher1" - And I follow "Statistics" + And I follow "Overview" And I follow "All appointments" And I click on "Student 1" "text" in the "4:00 AM" "table_row" Then I should see ", 4:00 AM" in the "Date and time" "table_row" @@ -76,7 +76,7 @@ Feature: Teachers can write notes on slots and appointments And I log out When I am on the "scheduler1" Activity page logged in as "edteacher1" - And I follow "Statistics" + And I follow "Overview" And I follow "All appointments" And I click on "Student 1" "text" in the "4:00 AM" "table_row" And I set the following fields to these values: diff --git a/tests/behat/overview_report.feature b/tests/behat/overview_report.feature new file mode 100644 index 00000000..024d37fb --- /dev/null +++ b/tests/behat/overview_report.feature @@ -0,0 +1,97 @@ +@javascript @mod @mod_scheduler @overview_report +Feature: Testing overview integration in scheduler activity + In order to summarize the scheduler activity + As a user + I need to be able to see the scheduler activity overview + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | manager1 | Manager | 1 | manager1@example.com | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + | student2 | Student | 2 | student2@example.com | + | student3 | Student | 3 | student3@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + | student2 | C1 | student | + | student3 | C1 | student | + And the following "groups" exist: + | name | course | idnumber | + | Group 1 | C1 | G1 | + | Group 2 | C1 | G2 | + And the following "group members" exist: + | user | group | + | student1 | G1 | + | student2 | G2 | + And the following "groupings" exist: + | name | course | idnumber | + | Grouping 1 | C1 | GG1 | + And the following "grouping groups" exist: + | grouping | group | + | GG1 | G1 | + And the following "system role assigns" exist: + | user | role | + | manager1 | manager | + And the following "activities" exist: + | activity | name | intro | course | idnumber | groupmode | schedulermode | maxbookings | guardtime | + | scheduler | Test scheduler | n | C1 | scheduler1 | 0 | oneonly | 1 | 172800 | + And the following "mod_scheduler > slots" exist: + | scheduler | starttime | duration | teacher | exclusivity | student | hideuntil | + # Slot 1 is available to only 1 student and is not yet booked + | scheduler1 | ##+5 days 1:00am## | 45 | teacher1 | 1 | | | + # Slot 2 is available to only 1 student and is already booked + | scheduler1 | ##+5 days 2:00am## | 45 | teacher1 | 1 | student3 | | + # Slot 3 is a group slot that is empty + | scheduler1 | ##+5 days 3:00am## | 45 | teacher1 | 3 | | | + # Slot 4 is a group slot that is partially booked + | scheduler1 | ##+5 days 4:00am## | 45 | teacher1 | 2 | student3 | | + # Slot 5 is an unlimited group slot that is empty + | scheduler1 | ##+5 days 5:00am## | 45 | teacher1 | 0 | | | + # Slot 6 is an unlimited group slot that is partially booked + | scheduler1 | ##+5 days 6:00am## | 45 | teacher1 | 0 | student3 | | + # Slot 7 is not yet available to students + | scheduler1 | ##+5 days 7:00am## | 45 | teacher1 | 0 | | ##now +2years## | + # Slot 8 is no longer available since the it's too close in the future + | scheduler1 | ##tomorrow 8:00am## | 45 | teacher1 | 0 | | | + + Scenario: The Scheduler activity index redirect to the activities overview + Given the site is running Moodle version 5.0 or higher + When I am on the "C1" "course > activities > scheduler" page logged in as "admin" + Then I should see "Name" in the "scheduler_overview_collapsible" "region" + And I should see "Actions" in the "scheduler_overview_collapsible" "region" + And I should see "Test scheduler" + + Scenario: View a group scheduler in the activities overview + Given the site is running Moodle version 5.0 or higher + And the following "activities" exist: + | activity | name | intro | course | idnumber | groupmode | + | scheduler | Group test scheduler | n | C1 | schedulerVis | 2 | + And I log in as "teacher1" + And I add 5 slots 11 days ahead in "schedulerVis" scheduler and I fill the form with: + | Location | Here | + When I am on the "C1" "course > activities > scheduler" page logged in as "admin" + Then I should see "An overview of all activities in the course" + And I should see "Name" in the "scheduler_overview_collapsible" "region" + And I should see "Actions" in the "scheduler_overview_collapsible" "region" + And I should see "Group test scheduler" + + Scenario: Use the selected grouping in the activities overview + Given the site is running Moodle version 5.0 or higher + And the following config values are set as admin: + | groupscheduling | 1 | mod_scheduler | + And the following "activities" exist: + | activity | name | intro | course | idnumber | groupmode | + | scheduler | Group test scheduler | n | C1 | schedulerVis | 0 | + And I am on the "schedulerVis" "scheduler activity editing" page logged in as "admin" + And I set the field "Booking in groups" to "Yes, in grouping Grouping 1" + And I press "Save and return to course" + When I am on the "C1" "course > activities > scheduler" page + Then I should see "Group test scheduler" + And I should see "1 of 1 groups" + And I should not see "2 of 2 groups" diff --git a/tests/behat/studentdata.feature b/tests/behat/studentdata.feature index 2330caf6..8f409c8c 100644 --- a/tests/behat/studentdata.feature +++ b/tests/behat/studentdata.feature @@ -62,7 +62,7 @@ Feature: Student-supplied data And I log out When I am on the "scheduler1" Activity page logged in as "teacher1" - And I follow "Statistics" + And I follow "Overview" And I follow "My appointments" Then I should see "Student 1" in the "3:00 AM" "table_row" And I should see "Student 2" in the "4:00 AM" "table_row" diff --git a/tests/behat/teacherpermissions.feature b/tests/behat/teacherpermissions.feature index 5fa44025..6fb24905 100644 --- a/tests/behat/teacherpermissions.feature +++ b/tests/behat/teacherpermissions.feature @@ -38,7 +38,7 @@ Feature: Teachers can edit other teacher's appointments only by permission Scenario: Editing teachers edit all appointments, nonediting teachers only their own When I am on the "scheduler1" Activity page logged in as "edteacher1" - And I follow "Statistics" + And I follow "Overview" And I follow "All appointments" Then I should see "Student 1" in the "3:00 AM" "table_row" And "seen[]" "checkbox" should exist in the "3:00 AM" "table_row" @@ -58,7 +58,7 @@ Feature: Teachers can edit other teacher's appointments only by permission And I log out When I am on the "scheduler1" Activity page logged in as "neteacher1" - And I follow "Statistics" + And I follow "Overview" And I follow "All appointments" Then I should see "Student 1" in the "3:00 AM" "table_row" And "seen[]" "checkbox" should not exist in the "3:00 AM" "table_row" @@ -76,7 +76,7 @@ Feature: Teachers can edit other teacher's appointments only by permission Then the field "Attended" matches value "1" When I am on the "scheduler1" Activity page - And I follow "Statistics" + And I follow "Overview" And I follow "All appointments" And I click on "Student 3" "text" in the "5:00 AM" "table_row" Then the "Attended" "checkbox" should be disabled @@ -93,7 +93,7 @@ Feature: Teachers can edit other teacher's appointments only by permission And I log out When I am on the "scheduler1" Activity page logged in as "neteacher1" - And I follow "Statistics" + And I follow "Overview" And I follow "All appointments" Then I should see "Student 1" in the "3:00 AM" "table_row" And "seen[]" "checkbox" should exist in the "3:00 AM" "table_row" @@ -109,7 +109,7 @@ Feature: Teachers can edit other teacher's appointments only by permission Then the field "Attended" matches value "1" When I am on the "scheduler1" Activity page - And I follow "Statistics" + And I follow "Overview" And I follow "All appointments" When I click on "Student 3" "text" in the "5:00 AM" "table_row" Then the "Attended" "checkbox" should be enabled @@ -130,7 +130,7 @@ Feature: Teachers can edit other teacher's appointments only by permission And I log out When I am on the "scheduler1" Activity page logged in as "neteacher1" - And I follow "Statistics" + And I follow "Overview" And I follow "All appointments" Then I should see "Student 1" in the "3:00 AM" "table_row" And "seen[]" "checkbox" should not exist in the "3:00 AM" "table_row" @@ -146,7 +146,7 @@ Feature: Teachers can edit other teacher's appointments only by permission Then the field "Grade" matches value "42" When I am on the "scheduler1" Activity page - And I follow "Statistics" + And I follow "Overview" And I follow "All appointments" And I click on "Student 3" "text" in the "5:00 AM" "table_row" Then the "grade" "field" should be enabled @@ -168,7 +168,7 @@ Feature: Teachers can edit other teacher's appointments only by permission And I log out When I am on the "scheduler1" Activity page logged in as "neteacher1" - And I follow "Statistics" + And I follow "Overview" And I follow "All appointments" Then I should see "Student 1" in the "3:00 AM" "table_row" And "seen[]" "checkbox" should not exist in the "3:00 AM" "table_row" @@ -187,7 +187,7 @@ Feature: Teachers can edit other teacher's appointments only by permission And I should see "notes-confid" When I am on the "scheduler1" Activity page - And I follow "Statistics" + And I follow "Overview" And I follow "All appointments" And I click on "Student 3" "text" in the "5:00 AM" "table_row" Then "grade" "field" should not exist diff --git a/tests/behat/tutorappointments.feature b/tests/behat/tutorappointments.feature index 12d908b4..c8d02627 100644 --- a/tests/behat/tutorappointments.feature +++ b/tests/behat/tutorappointments.feature @@ -184,7 +184,7 @@ Feature: Booking of appointments with individual tutors per group And I log out When I am on the "scheduler1" Activity page logged in as "manager1" - And I follow "Statistics" + And I follow "Overview" And I follow "My appointments" Then "slotmanager" "table" should not exist And I should see "No students available for scheduling" diff --git a/tests/behat/viewslots.feature b/tests/behat/viewslots.feature index f5cfe1a0..219f7818 100644 --- a/tests/behat/viewslots.feature +++ b/tests/behat/viewslots.feature @@ -14,7 +14,7 @@ Feature: Students viewing slots available for booking | student3 | Student | 3 | student3@example.com | And the following "courses" exist: | fullname | shortname | category | - | Course 1 | C1 | 0 | + | Course 1 | C1 | 0 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | @@ -148,3 +148,33 @@ Feature: Students viewing slots available for booking And I should not see "8:00 AM" in the "slotbookertable" "table" And I log out + + Scenario: Book slot button appears for student only when meeting was booked + Given I am on the "scheduler1" Activity page logged in as teacher1 + And I click on "Add slots" "link" + And I follow "Add single slot" + And I set the following fields to these values: + | starttime[day] | 1 | + | starttime[month] | April | + | starttime[year] | 2050 | + | duration | 30 | + | Location | Online test meeting | + | Meeting link | https://example.com/test-meeting | + When I click on "Save changes" "button" + Then I should see "1 slot added" + And "Join online meeting" "link" should exist + And I log out + + When I am on the "scheduler1" Activity page logged in as "student1" + Then I should see "Online test meeting" + And "Join online meeting" "link" should not exist + + When I click on "Book slot" "button" in the "Online test meeting" "table_row" + Then "Cancel booking" "button" should exist + And "Join online meeting" "link" should exist + And "a[href='https://example.com/test-meeting']" "css_element" should exist + + When I click on "Cancel booking" "button" + Then "Book slot" "button" should exist in the "Online test meeting" "table_row" + And "Join online meeting" "link" should not exist + And I log out diff --git a/tests/scheduler_test.php b/tests/scheduler_test.php index 8cf646e3..532e6fea 100644 --- a/tests/scheduler_test.php +++ b/tests/scheduler_test.php @@ -565,4 +565,103 @@ public function test_bookable_appointments(): void { $sid = $this->create_data_for_bookable_appointments('onetime', 1, 5 * DAYSECS, $studid, [2], []); $this->assert_bookable_appointments(0, 0, $sid, $studid); } + + /** + * Tests group booking detection for overlapping groups. + * + * @return void + * @throws \coding_exception + * @covers \mod_scheduler\model\scheduler::has_slots_booked_for_group + * @covers \mod_scheduler\model\scheduler::get_groups_for_scheduling + */ + public function test_has_slots_booked_for_group(): void { + $student1 = $this->create_student(); + $student2 = $this->create_student(); + + $groupa = $this->getDataGenerator()->create_group([ + 'courseid' => $this->courseid, + 'name' => 'Group A', + ]); + $groupb = $this->getDataGenerator()->create_group([ + 'courseid' => $this->courseid, + 'name' => 'Group B', + ]); + + // Student 1 belongs to both groups. + $this->getDataGenerator()->create_group_member([ + 'groupid' => $groupa->id, + 'userid' => $student1, + ]); + $this->getDataGenerator()->create_group_member([ + 'groupid' => $groupb->id, + 'userid' => $student1, + ]); + + // Student 2 only to one. + $this->getDataGenerator()->create_group_member([ + 'groupid' => $groupa->id, + 'userid' => $student2, + ]); + + $scheduler = scheduler::load_by_id($this->schedulerid); + $scheduler->bookingrouping = 0; + $scheduler->save(); + $teacherid = $this->getDataGenerator()->create_user()->id; + + // Individual booking does not count as a booking for groups. + $individualslot = $scheduler->create_slot(); + $individualslot->teacherid = $teacherid; + $individualslot->starttime = time() + DAYSECS; + $individualslot->duration = 10; + + $individualappointment = $individualslot->create_appointment(); + $individualappointment->studentid = $student1; + $individualslot->save(); + + $this->assertFalse($scheduler->has_slots_booked_for_group($groupa->id)); + $this->assertFalse($scheduler->has_slots_booked_for_group($groupb->id)); + + $groupsforscheduling = $scheduler->get_groups_for_scheduling(); + $this->assertArrayHasKey($groupa->id, $groupsforscheduling); + $this->assertArrayHasKey($groupb->id, $groupsforscheduling); + + // Book Group A. + $groupslot = $scheduler->create_slot(); + $groupslot->teacherid = $teacherid; + $groupslot->starttime = time() + 2 * DAYSECS; + $groupslot->duration = 10; + + foreach ([$student1, $student2 ] as $studentid) { + $appointment = $groupslot->create_appointment(); + $appointment->studentid = $studentid; + $appointment->bookinggroupid = $groupa->id; + } + + $groupslot->save(); + + $groupsforscheduling = $scheduler->get_groups_for_scheduling(); + $this->assertArrayNotHasKey($groupa->id, $groupsforscheduling); + $this->assertArrayHasKey($groupb->id, $groupsforscheduling); + + $this->assertTrue($scheduler->has_slots_booked_for_group($groupa->id)); + $this->assertFalse($scheduler->has_slots_booked_for_group($groupb->id)); + + // Book group B separately. + $secondgroupslot = $scheduler->create_slot(); + $secondgroupslot->teacherid = $teacherid; + $secondgroupslot->starttime = time() + 3 * DAYSECS; + $secondgroupslot->duration = 10; + + $appointment = $secondgroupslot->create_appointment(); + $appointment->studentid = $student1; + $appointment->bookinggroupid = $groupb->id; + $secondgroupslot->save(); + + $groupsforscheduling = $scheduler->get_groups_for_scheduling(); + $this->assertArrayNotHasKey($groupa->id, $groupsforscheduling); + $this->assertArrayNotHasKey($groupb->id, $groupsforscheduling); + + $this->assertTrue($scheduler->has_slots_booked_for_group($groupa->id)); + $this->assertTrue($scheduler->has_slots_booked_for_group($groupb->id)); + } } diff --git a/tests/slot_test.php b/tests/slot_test.php index 36e73796..5af00395 100644 --- a/tests/slot_test.php +++ b/tests/slot_test.php @@ -294,6 +294,204 @@ public function test_calendar_events(): void { $this->assert_event_absent($this->teacherid, $newstart); } + /** + * Test that attended appointments are removed from calendars by default. + * + * @covers \mod_scheduler\model\scheduler::load_by_id + */ + public function test_attended_appointments_are_removed_from_calendar_by_default(): void { + + set_config('keepattendedincalendar', 0, 'mod_scheduler'); + + $scheduler = scheduler::load_by_id($this->schedulerid); + $slot = slot::load_by_id($this->slotid, $scheduler); + + $appointment = $slot->get_appointment($this->appointmentids[0]); + $appointment->attended = 1; + $slot->save(); + + $this->assert_event_absent($this->students[0], $slot->starttime); + } + + /** + * Test that attended appointments remain visible in calendars. + * + * @covers \mod_scheduler\model\scheduler::load_by_id + */ + public function test_attended_appointments_keep_calendar_events(): void { + + set_config('keepattendedincalendar', 1, 'mod_scheduler'); + + $scheduler = scheduler::load_by_id($this->schedulerid); + $slot = slot::load_by_id($this->slotid, $scheduler); + + $appointment = $slot->get_appointment($this->appointmentids[0]); + $appointment->attended = 1; + $slot->save(); + + $this->assert_event_exists($this->students[0], $slot->starttime, "Meeting with your Teacher"); + $this->assert_event_exists($this->teacherid, $slot->starttime, "Meeting with your Students"); + } + + /** + * Test creating and reusing a course group for a slot. + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + * @covers ::scheduler_create_coursegroup + */ + public function test_creates_coursegroup(): void { + global $DB; + $this->setAdminUser(); + $scheduler = scheduler::load_by_id($this->schedulerid); + $slot = slot::load_by_id($this->slotid, $scheduler); + + // Create a group. + $groupid = \scheduler_create_coursegroup($slot); + $group = $DB->get_record('groups', ['id' => $groupid], '*', MUST_EXIST); + + $this->assertEquals($this->courseid, $group->courseid); + $this->assertNotEmpty($group->name); + $this->assertEquals($groupid, $slot->coursegroupid); + $this->assertEquals($groupid, $DB->get_field('scheduler_slots', 'coursegroupid', ['id' => $this->slotid])); + $this->assertEquals(1, $DB->count_records('groups', ['courseid' => $this->courseid])); + + // Rename the group. + $group->name = 'Renamed group'; + \groups_update_group($group); + + $slot = slot::load_by_id($this->slotid, $scheduler); + $reuseid = \scheduler_create_coursegroup($slot); + + $this->assertEquals($groupid, $reuseid); + $this->assertEquals(1, $DB->count_records('groups', ['courseid' => $this->courseid])); + $this->assertEquals('Renamed group', $DB->get_field('groups', 'name', ['id' => $groupid])); + } + + /** + * Cancellation removes a member and slot deletion should preserve the group. + * + * @return void + * @throws \dml_exception + * @covers \mod_scheduler\course_group_helper::add_booked_students + * @covers \mod_scheduler\course_group_helper::remove_booked_students + * @covers \mod_scheduler\event\booking_added::create_from_slot + * @covers \mod_scheduler\event\booking_removed::create_from_slot + * @covers \mod_scheduler\observer::booking_added + * @covers \mod_scheduler\observer::booking_removed + * @covers \mod_scheduler\model\slot::delete + */ + public function test_cancellation_and_slot_deletion_group_memberships(): void { + global $DB; + + $this->setAdminUser(); + + foreach ($this->students as $studentid) { + $this->getDataGenerator()->enrol_user($studentid, $this->courseid, 'student'); + } + + $scheduler = scheduler::load_by_id($this->schedulerid); + $scheduler->groupcreation = 2; + $scheduler->save(); + + $slot = slot::load_by_id($this->slotid, $scheduler); + $slot->exclusivity = 3; + $slot->save(); + + \mod_scheduler\event\booking_added::create_from_slot($slot)->trigger(); + $groupid = $DB->get_field('scheduler_slots', 'coursegroupid', ['id' => $this->slotid], MUST_EXIST); + + foreach ($this->students as $studentid) { + $this->assertTrue(\groups_is_member($groupid, $studentid)); + } + + // Cancel the first student's appointment. + $appointment = $slot->get_student_appointment($this->students[0]); + $appointmentdata = $appointment->get_data(); + + $slot->remove_appointment($appointment); + $slot->save(); + + \mod_scheduler\event\booking_removed::create_from_slot($slot, $appointmentdata)->trigger(); + + $this->assertFalse(\groups_is_member($groupid, $this->students[0])); + $this->assertTrue(\groups_is_member($groupid, $this->students[1])); + $this->assertTrue(\groups_is_member($groupid, $this->students[2])); + + // Deleting slot must preserve group and remaining members. + + $slot->delete(); + $this->assertFalse($DB->record_exists('scheduler_slots', ['id' => $this->slotid])); + $this->assertTrue($DB->record_exists('groups', ['id' => $groupid])); + $this->assertTrue(\groups_is_member($groupid, $this->students[1])); + $this->assertTrue(\groups_is_member($groupid, $this->students[2])); + } + + /** + * Tests creating a course group when a group slot is added. + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @covers ::scheduler_create_coursegroup + * @covers \mod_scheduler\event\slot_added::create_from_slot + * @covers \mod_scheduler\observer::slot_added + */ + public function test_slot_added_event_creates_coursegroup(): void { + global $DB; + + $this->setAdminUser(); + $scheduler = scheduler::load_by_id($this->schedulerid); + $scheduler->groupcreation = 1; + $scheduler->save(); + + $slot = slot::load_by_id($this->slotid, $scheduler); + $slot->exclusivity = 3; + $slot->save(); + + \mod_scheduler\event\slot_added::create_from_slot($slot)->trigger(); + + $groupid = $DB->get_field('scheduler_slots', 'coursegroupid', ['id' => $this->slotid], MUST_EXIST); + + $this->assertNotEmpty($groupid); + $this->assertTrue($DB->record_exists('groups', ['id' => $groupid, 'courseid' => $this->courseid])); + } + + /** + * Tests that an existing group booking does not create another course group. + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @covers \mod_scheduler\course_group_helper::add_booked_students + * @covers \mod_scheduler\event\booking_added::create_from_slot + * @covers \mod_scheduler\observer::booking_added + */ + public function test_group_bookings_does_not_create_coursegroup(): void { + global $DB; + + $this->setAdminUser(); + $scheduler = scheduler::load_by_id($this->schedulerid); + $scheduler->groupcreation = 2; + $scheduler->save(); + + $slot = slot::load_by_id($this->slotid, $scheduler); + $slot->exclusivity = 3; + $slot->save(); + + $bookedgroup = $this->getDataGenerator()->create_group(['courseid' => $this->courseid]); + + $DB->set_field('scheduler_appointment', 'bookinggroupid', $bookedgroup->id, ['id' => $this->slotid]); + + \mod_scheduler\event\slot_added::create_from_slot($slot)->trigger(); + + $coursegroupid = $DB->get_field('scheduler_slots', 'coursegroupid', ['id' => $this->slotid], MUST_EXIST); + + $this->assertEquals(0, $coursegroupid); + $this->assertEquals(1, $DB->count_records('groups', ['courseid' => $this->courseid])); + } + /** * Assert that a calendar event exists in the DB. * diff --git a/version.php b/version.php index d8c555c6..f871c06d 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ */ $plugin->component = 'mod_scheduler'; // Full name of the plugin (used for diagnostics). -$plugin->version = 2026080400; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2026102300; // The current module version (Date: YYYYMMDDXX). $plugin->release = 'v5.2-r1'; $plugin->requires = 2024100712; // Requires Moodle 4.5. $plugin->supported = [500, 502]; diff --git a/view.php b/view.php index e2d6b0fa..9a148640 100644 --- a/view.php +++ b/view.php @@ -64,6 +64,13 @@ $subpage = optional_param('subpage', $defaultsubpage, PARAM_ALPHA); +if ( + $action === 'export' || + $action === 'viewstatistics' || + $action === 'addsession' +) { + $PAGE->add_body_class('limitedwidth'); +} // Print the page header. diff --git a/viewstatistics.php b/viewstatistics.php index 6fb8165f..8d1a4e5c 100644 --- a/viewstatistics.php +++ b/viewstatistics.php @@ -121,17 +121,17 @@ function byname($a, $b) { $allattendees = ($attendees) ? count($attendees) : 0; $str = '

' . get_string('attendable', 'scheduler') . '

'; - $str .= '' . get_string('attendablelbl', 'scheduler') . ': ' . $allattendees . '
'; + $str .= '

' . get_string('attendablelbl', 'scheduler') . ': ' . $allattendees . '

'; $str .= '

' . get_string('attended', 'scheduler') . '

'; - $str .= '' . get_string('attendedlbl', 'scheduler') . ': ' . $attended . '

'; + $str .= '

' . get_string('attendedlbl', 'scheduler') . ': ' . $attended . '

'; $str .= '

' . get_string('unattended', 'scheduler') . '

'; - $str .= '' . get_string('registeredlbl', 'scheduler') . ': ' . $registered . '
'; + $str .= '

' . get_string('registeredlbl', 'scheduler') . ': ' . $registered . '
'; $str .= '' . get_string('unregisteredlbl', 'scheduler') . ': ' . - ($allattendees - $registered - $attended) . '
'; + ($allattendees - $registered - $attended) . '

'; $str .= '

' . get_string('availableslots', 'scheduler') . '

'; - $str .= '' . get_string('availableslotsowned', 'scheduler') . ': ' . $freeowned . '
'; + $str .= '

' . get_string('availableslotsowned', 'scheduler') . ': ' . $freeowned . '
'; $str .= '' . get_string('availableslotsnotowned', 'scheduler') . ': ' . $freenotowned . '
'; - $str .= '' . get_string('availableslotsall', 'scheduler') . ': ' . ($freeowned + $freenotowned) . '
'; + $str .= '' . get_string('availableslotsall', 'scheduler') . ': ' . ($freeowned + $freenotowned) . '

'; echo $OUTPUT->box($str); @@ -141,6 +141,7 @@ function byname($a, $b) { if (!empty($attendees)) { $table = new html_table(); + $table->attributes['class'] = 'table generaltable table-reboot table-hover table-striped'; $table->head = [get_string('student', 'scheduler'), get_string('duration', 'scheduler')]; $table->align = ['LEFT', 'CENTER']; $table->width = '70%'; @@ -193,6 +194,7 @@ function byname($a, $b) { $sql .= " GROUP BY s.teacherid"; if ($statrecords = $DB->get_records_sql($sql, $params)) { $table = new html_table(); + $table->attributes['class'] = 'table generaltable table-reboot table-hover table-striped'; $table->width = '70%'; $table->head = [s($scheduler->get_teacher_name()), get_string('cumulatedduration', 'scheduler')]; $table->align = ['LEFT', 'CENTER']; @@ -230,6 +232,7 @@ function byname($a, $b) { $sql .= " GROUP BY s.starttime ORDER BY groupsize DESC"; if ($groupslots = $DB->get_records_sql($sql, $params)) { $table = new html_table(); + $table->attributes['class'] = 'table generaltable table-reboot table-hover table-striped'; $table->head = [get_string('duration', 'scheduler'), get_string('appointments', 'scheduler')]; $table->align = ['LEFT', 'CENTER']; $table->width = '70%'; @@ -276,6 +279,7 @@ function byname($a, $b) { ORDER BY groupsize DESC"; if ($groupslots = $DB->get_records_sql($sql, $params)) { $table = new html_table(); + $table->attributes['class'] = 'table generaltable table-reboot table-hover table-striped'; $table->head = [get_string('groupsize', 'scheduler'), get_string('occurrences', 'scheduler'), get_string('cumulatedduration', 'scheduler'), ]; $table->align = ['LEFT', 'CENTER', 'CENTER'];