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..60ee90f7 100644 --- a/backup/moodle2/backup_scheduler_stepslib.php +++ b/backup/moodle2/backup_scheduler_stepslib.php @@ -47,19 +47,19 @@ protected function define_structure() { 'scale', 'gradingstrategy', 'bookingrouping', 'usenotes', 'usebookingform', 'bookinginstructions', 'bookinginstructionsformat', 'usestudentnotes', 'requireupload', 'uploadmaxfiles', 'uploadmaxsize', - 'usecaptcha', 'timemodified', ]); + 'usecaptcha', 'timemodified', 'groupcreation', ]); $slots = new backup_nested_element('slots'); $slot = new backup_nested_element('slot', ['id'], [ 'starttime', 'duration', 'teacherid', 'appointmentlocation', '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', ]); @@ -86,7 +86,9 @@ protected function define_structure() { if ($userinfo) { $slot->annotate_ids('user', 'teacherid'); + $slot->annotate_ids('group', 'coursegroupid'); $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..8b1e5354 100644 --- a/backup/moodle2/restore_scheduler_stepslib.php +++ b/backup/moodle2/restore_scheduler_stepslib.php @@ -84,6 +84,8 @@ protected function process_scheduler($data) { $data->bookingrouping = $this->get_mappingid('grouping', $data->bookingrouping); } + $data->groupcreation = (int) ($data->groupcreation ?? 0); + // Insert the scheduler record. $newitemid = $DB->insert_record('scheduler', $data); // Immediately after inserting "activity" record, call this. @@ -106,9 +108,12 @@ 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); + $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 +136,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..ab2f7ca1 100644 --- a/classes/model/scheduler.php +++ b/classes/model/scheduler.php @@ -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..e9044bf0 100644 --- a/db/install.xml +++ b/db/install.xml @@ -20,6 +20,7 @@ + @@ -51,6 +52,7 @@ + @@ -64,6 +66,7 @@ + @@ -84,4 +87,4 @@ - \ No newline at end of file + diff --git a/db/upgrade.php b/db/upgrade.php index fc82aeca..16643504 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -416,5 +416,48 @@ 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'); + } 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..b575974e 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,9 @@ $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['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'; @@ -287,6 +302,12 @@ $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,6 +325,8 @@ $string['includeemptyslots'] = 'Include empty slots'; $string['includeslotsfor'] = 'Include slots for'; $string['incourse'] = ' in course '; +$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'] = 'Mix individual and group bookings'; $string['mixindivgroup_desc'] = 'Where group scheduling is enabled, allow individual bookings as well.'; $string['introduction'] = 'Introduction'; @@ -340,6 +363,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/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..a75ba3c6 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'), @@ -107,6 +108,16 @@ public function definition() { $mform->setDefault('bookingrouping', '-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'); @@ -261,4 +272,18 @@ public function save_mod_data(stdClass $data, context_module $context) { $DB->update_record('scheduler', $data); } } + + /** + * 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..a6d2eb4e 100644 --- a/renderable.php +++ b/renderable.php @@ -667,11 +667,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..38e96d6b 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']); } /** @@ -416,6 +416,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')]; @@ -610,7 +611,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 +646,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 +682,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()), @@ -834,6 +839,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 +849,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); @@ -963,6 +975,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')]; 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..2aa19cf0 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; @@ -260,6 +261,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 = []; @@ -531,10 +536,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 +551,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 +595,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; 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..b355f8dc 100644 --- a/teacherview.controller.php +++ b/teacherview.controller.php @@ -299,8 +299,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 +328,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 @@ (?:[^"]|\\")*)" 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/groupscheduling.feature b/tests/behat/groupscheduling.feature index c8737341..5c2ad42f 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,44 @@ 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" + 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 "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" 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/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..798ac3a1 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 = 2026100100; // 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'];