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 @@