From a0a4a78643e34e37764efa4121608fe832ab3865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luca=20B=C3=B6sch?= Date: Sun, 28 Dec 2025 12:54:01 +0100 Subject: [PATCH] Support course overview, resolves #146. --- classes/courseformat/overview.php | 192 ++++++++++++++++++++++++++++ classes/manager.php | 164 ++++++++++++++++++++++++ classes/model/scheduler.php | 41 ++++++ index.php | 5 + lang/en/scheduler.php | 5 + tests/behat/overview_report.feature | 82 ++++++++++++ 6 files changed, 489 insertions(+) create mode 100644 classes/courseformat/overview.php create mode 100644 classes/manager.php create mode 100644 tests/behat/overview_report.feature 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/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/scheduler.php b/classes/model/scheduler.php index a4f043bc..cc18005f 100644 --- a/classes/model/scheduler.php +++ b/classes/model/scheduler.php @@ -1204,6 +1204,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->cm->groupingid); + } + /** * Get a list of students that can still make an appointment. * @@ -1241,6 +1250,38 @@ 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() { + global $DB; + // Get all groups that can book slots. + $groups = $this->get_available_groups(); + + // Remove groups that already contain a student with an appointment. + $sql = "SELECT DISTINCT a.studentid + FROM {scheduler_appointment} a + JOIN {scheduler_slots} s ON a.slotid = s.id + WHERE s.schedulerid = :sid"; + $studentrecs = $DB->get_records_sql($sql, ['sid' => $this->id]); + if ($studentrecs) { + foreach ($studentrecs as $r) { + $studentid = $r->studentid; + $studentgroups = groups_get_all_groups($this->courseid, $studentid, $this->cm->groupingid); + if ($studentgroups) { + foreach ($studentgroups as $g) { + $gid = (string)$g->id; + if (isset($groups[$gid])) { + unset($groups[$gid]); + } + } + } + } + } + return $groups; + } /** * Delete an appointment, and do whatever is needed 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..97bf5180 100644 --- a/lang/en/scheduler.php +++ b/lang/en/scheduler.php @@ -128,6 +128,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,6 +196,7 @@ $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['createexport'] = 'Create export file'; $string['csvformat'] = 'CSV'; @@ -340,6 +344,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/tests/behat/overview_report.feature b/tests/behat/overview_report.feature new file mode 100644 index 00000000..306df165 --- /dev/null +++ b/tests/behat/overview_report.feature @@ -0,0 +1,82 @@ +@javascript @mod @mod_scheduler +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"