diff --git a/lang/en/course.php b/lang/en/course.php index 4255e127636..b279203944d 100644 --- a/lang/en/course.php +++ b/lang/en/course.php @@ -97,6 +97,7 @@ $string['enabledownloadcoursecontent'] = 'Enable download course content'; $string['errorendbeforestart'] = 'The end date ({$a}) is before the course start date.'; $string['favourite'] = 'Starred course'; $string['gradetopassnotset'] = 'This course does not have a grade to pass set. It may be set in the grade item of the course (Gradebook setup).'; +$string['hideendedcoursestask'] = 'Hide courses on end date'; $string['informationformodule'] = 'Information about the {$a} activity'; $string['module'] = 'Activity'; $string['namewithlink'] = 'Category name with link'; @@ -128,6 +129,7 @@ $string['relativedatessubmissionduedatebefore'] = '{$a->datediffstr} before cour $string['searchactivitiesbyname'] = 'Search for activities by name'; $string['searchresults'] = 'Search results: {$a}'; $string['sectionlink'] = 'Permalink'; +$string['showstartedcoursestask'] = 'Show courses on start date'; $string['submitsearch'] = 'Submit search'; $string['studentsatriskincourse'] = 'Students at risk in {$a} course'; $string['studentsatriskinfomessage'] = 'Hi {$a->userfirstname}, diff --git a/lang/en/moodle.php b/lang/en/moodle.php index 1c9bf5a805d..c6998065a81 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -814,6 +814,7 @@ $string['eventcoursecategoryviewed'] = 'Category viewed'; $string['eventcoursecontentdeleted'] = 'Course content deleted'; $string['eventcoursecreated'] = 'Course created'; $string['eventcoursedeleted'] = 'Course deleted'; +$string['eventcourseended'] = 'Course ended'; $string['eventcourseinformationviewed'] = 'Course summary viewed'; $string['eventcoursemodulecreated'] = 'Course module created'; $string['eventcoursemoduledeleted'] = 'Course module deleted'; @@ -823,6 +824,7 @@ $string['eventcoursessearched'] = 'Courses searched'; $string['eventcourseresetended'] = 'Course reset ended'; $string['eventcourseresetstarted'] = 'Course reset started'; $string['eventcourserestored'] = 'Course restored'; +$string['eventcoursestarted'] = 'Course started'; $string['eventcourseupdated'] = 'Course updated'; $string['eventcoursesectioncreated'] = 'Course section created'; $string['eventcoursesectiondeleted'] = 'Course section deleted'; diff --git a/lib/classes/event/course_ended.php b/lib/classes/event/course_ended.php new file mode 100644 index 00000000000..d371e434119 --- /dev/null +++ b/lib/classes/event/course_ended.php @@ -0,0 +1,96 @@ +. + +namespace core\event; + +/** + * Course ended event class. + * + * @property-read array $other { + * Extra information about event. + * + * - string fullname: fullname of course. + * - string shortname: (optional) shortname of course. + * } + * + * @package core + * @copyright 2023 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_ended extends base { + + /** + * Initialise the event data. + */ + protected function init() { + $this->data['objecttable'] = 'course'; + $this->data['crud'] = 'u'; + $this->data['edulevel'] = self::LEVEL_TEACHING; + } + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name() { + return get_string('eventcourseended'); + } + + /** + * Returns non-localised description of what happened. + * + * @return string + */ + public function get_description() { + return "The course with id '$this->courseid' has ended."; + } + + /** + * Returns relevant URL. + * + * @return \moodle_url + */ + public function get_url() { + return new \moodle_url('/course/view.php', ['id' => $this->objectid]); + } + + /** + * Custom validation. + * + * @throws \coding_exception + * @return void + */ + protected function validate_data() { + parent::validate_data(); + + if (!isset($this->other['fullname'])) { + throw new \coding_exception('The \'fullname\' value must be set in other.'); + } + } + + public static function get_objectid_mapping() { + return [ + 'db' => 'course', + 'restore' => 'course', + ]; + } + + public static function get_other_mapping() { + // Nothing to map. + return []; + } +} diff --git a/lib/classes/event/course_started.php b/lib/classes/event/course_started.php new file mode 100644 index 00000000000..f2976749f5a --- /dev/null +++ b/lib/classes/event/course_started.php @@ -0,0 +1,96 @@ +. + +namespace core\event; + +/** + * Course started event class. + * + * @property-read array $other { + * Extra information about event. + * + * - string fullname: fullname of course. + * - string shortname: (optional) shortname of course. + * } + * + * @package core + * @copyright 2023 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_started extends base { + + /** + * Initialise the event data. + */ + protected function init() { + $this->data['objecttable'] = 'course'; + $this->data['crud'] = 'u'; + $this->data['edulevel'] = self::LEVEL_TEACHING; + } + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name() { + return get_string('eventcoursestarted'); + } + + /** + * Returns non-localised description of what happened. + * + * @return string + */ + public function get_description() { + return "The course with id '$this->courseid' has started."; + } + + /** + * Returns relevant URL. + * + * @return \moodle_url + */ + public function get_url() { + return new \moodle_url('/course/view.php', ['id' => $this->objectid]); + } + + /** + * Custom validation. + * + * @throws \coding_exception + * @return void + */ + protected function validate_data() { + parent::validate_data(); + + if (!isset($this->other['fullname'])) { + throw new \coding_exception('The \'fullname\' value must be set in other.'); + } + } + + public static function get_objectid_mapping() { + return [ + 'db' => 'course', + 'restore' => 'course', + ]; + } + + public static function get_other_mapping() { + // Nothing to map. + return []; + } +} diff --git a/lib/classes/task/hide_ended_courses_task.php b/lib/classes/task/hide_ended_courses_task.php new file mode 100644 index 00000000000..12c95571716 --- /dev/null +++ b/lib/classes/task/hide_ended_courses_task.php @@ -0,0 +1,52 @@ +. + +namespace core\task; + +/** + * Simple task to automatically set the course visibility to hidden when the course end date matches the current day. + * + * @package core + * @copyright 2023 Sara Arjona based on code from 2016 Tim Gagen and Amanda Doughty + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class hide_ended_courses_task extends show_started_courses_task { + + /** + * Get a descriptive name for this task (shown to admins). + * + * @return string + */ + public function get_name() { + return get_string('hideendedcoursestask', 'course'); + } + + protected function get_field_date(): string { + return 'enddate'; + } + + protected function get_visibility(): int { + return 0; + } + + protected function get_trace_message(): string { + return 'Hide'; + } + + protected function get_event_classname(): string { + return '\core\event\course_ended'; + } +} diff --git a/lib/classes/task/show_started_courses_task.php b/lib/classes/task/show_started_courses_task.php new file mode 100644 index 00000000000..c0a72bf13d7 --- /dev/null +++ b/lib/classes/task/show_started_courses_task.php @@ -0,0 +1,149 @@ +. + +namespace core\task; + +/** + * Simple task to automatically set the course visibility to shown when the course start date matches the current day. + * + * @package core + * @copyright 2023 Sara Arjona based on code from 2016 Tim Gagen and Amanda Doughty + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class show_started_courses_task extends scheduled_task { + + /** + * Get a descriptive name for this task (shown to admins). + * + * @return string + */ + public function get_name() { + return get_string('showstartedcoursestask', 'course'); + } + + /** + * Update course visibility. + * IMPORTANT: It only processes courses with start/end dates within the past 24 hours and with start/end dates higher than + * the current one, to avoid updating the course visibility early. + * + * @return void + */ + public function execute() { + global $CFG, $DB; + + // Use the configured timezone. + date_default_timezone_set($CFG->timezone); + + $start = time(); + + // Get list of courses to update. + mtrace('\n Searching for courses to set visibility to ' . $this->get_trace_message() . ' ...'); + $fielddate = $this->get_field_date(); + // Only process courses with dates in the past 24 hours with start/end dates higher than the current, to avoid updating + // the course visibility early. + $select = "visible = :visibility AND + {$fielddate} BETWEEN :beginofday AND :endofday"; + $params = [ + // Get courses that have the opposite visibility to the one we want to set. + 'visibility' => !$this->get_visibility(), + 'beginofday' => strtotime('-1 day', $start), + 'endofday' => $start, + ]; + $courses = $DB->get_recordset_select('course', $select, $params); + $this->update_courses_visibility($courses, $this->get_visibility()); + $courses->close(); + + $end = time(); + mtrace(($end - $start) / 60 . ' mins'); + } + + /** + * Make course visible or hidden if the start date has become due. + * + * @param \moodle_recordset $courses + * @param int $visibility The given courses will be set to this visibility + * @return void + */ + private function update_courses_visibility(\moodle_recordset $courses, int $visibility): void { + global $DB; + + mtrace("\n There are courses to change visibility..."); + foreach ($courses as $course) { + if (!$DB->set_field('course', 'visible', $visibility, ['id' => $course->id])) { + mtrace(" Error updating course visibility for {$course->id}: {$course->shortname}."); + } else { + mtrace(" {$course->id}: {$course->shortname} visibility is now '" . $this->get_trace_message() . "'"); + $this->trigger_event($course); + } + } + } + + /** + * Method to trigger a course event. + * + * @param \stdClass $course The course that has been updated. + */ + private function trigger_event(\stdClass $course): void { + $params = [ + 'objectid' => $course->id, + 'context' => \context_course::instance($course->id), + 'other' => [ + 'shortname' => $course->shortname, + 'fullname' => $course->fullname, + 'idnumber' => $course->idnumber, + ], + ]; + $event = call_user_func([$this->get_event_classname(), 'create'], $params); + $event->add_record_snapshot('course', $course); + $event->trigger(); + } + + /** + * Get the database field where the date to check is stored (startdate for showing courses and enddate for hiding courses). + * + * @return string + */ + protected function get_field_date(): string { + return 'startdate'; + } + + /** + * The expected visibility of the courses after running this task (show = 1 and hidden = 0). + * + * @return int + */ + protected function get_visibility(): int { + return 1; + } + + /** + * The text to display in the trace message about the action that has been applied to the course. + * + * @return string + */ + protected function get_trace_message(): string { + return 'Show'; + } + + /** + * The event classname to be triggered for the courses that need to be updated. + * + * @return string + */ + protected function get_event_classname(): string { + return '\core\event\course_started'; + } +} diff --git a/lib/db/tasks.php b/lib/db/tasks.php index ef69023e0e1..99723de53fa 100644 --- a/lib/db/tasks.php +++ b/lib/db/tasks.php @@ -437,4 +437,24 @@ $tasks = array( 'dayofweek' => '*', 'month' => '*' ], + [ + 'classname' => 'core\task\show_started_courses_task', + 'blocking' => 0, + 'minute' => '00', + 'hour' => '01', + 'day' => '*', + 'dayofweek' => '*', + 'month' => '*', + 'disabled' => true, + ], + [ + 'classname' => 'core\task\hide_ended_courses_task', + 'blocking' => 0, + 'minute' => '00', + 'hour' => '01', + 'day' => '*', + 'dayofweek' => '*', + 'month' => '*', + 'disabled' => true, + ], ); diff --git a/lib/tests/task/hide_ended_courses_task_test.php b/lib/tests/task/hide_ended_courses_task_test.php new file mode 100644 index 00000000000..dc44c88d3c4 --- /dev/null +++ b/lib/tests/task/hide_ended_courses_task_test.php @@ -0,0 +1,119 @@ +. + +namespace core\task; + +defined('MOODLE_INTERNAL') || die; + +require_once(__DIR__ . '/show_started_courses_task_test.php'); + +/** + * Class containing unit tests for the hide ended courses task. + * + * It automatically sets the course visibility to hidden when the course end date matches the current day. + * + * @package core + * @copyright 2023 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \core\task\hide_ended_courses_task + */ +class hide_ended_courses_task_test extends \core\task\show_started_courses_task_test { + + /** + * Test hide_ended_courses cron task. + * + * @dataProvider get_courses_provider + * @covers ::execute + * + * @param int $nextweekvisible Number of courses with the end date set to next week to be created. + * @param int $yesterdayvisible Number of courses with the end date set to yesterday to be created. + * @param int $tomorrowvisible Number of courses with the end date set to tomorrow to be created. + * @param bool $createhidden Whether hidden courses should be created or not. + */ + public function test_hide_ended_courses( + int $nextweekvisible, + int $yesterdayvisible, + int $tomorrowvisible, + bool $createhidden = true + ) { + global $DB; + + $this->resetAfterTest(); + + $generator = $this->getDataGenerator(); + + $visiblecourses = []; + $hiddencourses = []; + + $now = time(); + $nextweek = $now + WEEKSECS; + $yesterday = $now - DAYSECS + MINSECS; + $tomorrow = $now + DAYSECS; + + // Visible course that finishes last week. + for ($i = 0; $i < $nextweekvisible; $i++) { + $generator->create_course(['visible' => true, 'enddate' => $nextweek]); + } + // Visible course that finished yesterday. + for ($i = 0; $i < $yesterdayvisible; $i++) { + $visiblecourses[] = $generator->create_course( + ['visible' => true, 'startdate' => $yesterday - MINSECS , 'enddate' => $yesterday] + )->id; + } + // Visible course that hasn't finished yet. + for ($i = 0; $i < $tomorrowvisible; $i++) { + $generator->create_course(['visible' => true, 'enddate' => $tomorrow]); + } + if ($createhidden) { + // Visible course that already finished. + $hiddencourses[] = $generator->create_course( + ['visible' => false, 'startdate' => $yesterday - MINSECS, 'enddate' => $yesterday] + )->id; + // Visible course that hasn't finished yet. + $hiddencourses[] = $generator->create_course(['visible' => false, 'enddate' => $tomorrow])->id; + } + $hiddentotal = count($hiddencourses); + // Course total also includes site course. + $coursetotal = $hiddentotal + $nextweekvisible + $yesterdayvisible + $tomorrowvisible + 1; + + // Check current courses have been created correctly. + $this->assertEquals($coursetotal, $DB->count_records('course')); + $this->assertEquals(count($hiddencourses), $DB->count_records('course', ['visible' => 0])); + + $sink = $this->redirectEvents(); + + // Run the hide ended courses task. + ob_start(); + $task = new hide_ended_courses_task(); + $task->execute(); + ob_end_clean(); + + // Confirm the courses with yesterday as ending date are hidden too. The rest should remain visible. + $courses = $DB->get_records('course', ['visible' => 0], '', 'id'); + $this->assertCount($hiddentotal + $yesterdayvisible, $courses); + $expected = array_merge($hiddencourses, $visiblecourses); + $this->assertEquals(asort($expected), asort($courses)); + + // Check the ended course event has been raised. + $events = $sink->get_events(); + $sink->close(); + $this->assertCount($yesterdayvisible, $events); + foreach ($events as $event) { + $this->assertInstanceOf('\\core\\event\\course_ended', $event); + $this->assertArrayHasKey($event->courseid, array_flip($expected)); + } + } +} diff --git a/lib/tests/task/show_started_courses_task_test.php b/lib/tests/task/show_started_courses_task_test.php new file mode 100644 index 00000000000..1d15c9ccb6c --- /dev/null +++ b/lib/tests/task/show_started_courses_task_test.php @@ -0,0 +1,166 @@ +. + +namespace core\task; + +use advanced_testcase; + +/** + * Class containing unit tests for the show started courses task. + * + * It automatically sets the course visibility to shown when the course start date matches the current day. + * + * @package core + * @copyright 2023 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \core\task\show_started_courses_task + */ +class show_started_courses_task_test extends advanced_testcase { + + /** + * Test show_started_courses cron task. + * + * @dataProvider get_courses_provider + * @covers ::execute + * + * @param int $lastweekhidden Number of courses with the start date set to last week to be created. + * @param int $yesterdayhidden Number of courses with the start date set to yesterday to be created. + * @param int $tomorrowhidden Number of courses with the start date set to tomorrow to be created. + * @param bool $createvisible Whether visible courses should be created or not. + */ + public function test_show_started_courses( + int $lastweekhidden, + int $yesterdayhidden, + int $tomorrowhidden, + bool $createvisible = true + ) { + global $DB; + + $this->resetAfterTest(); + + $generator = $this->getDataGenerator(); + + $visiblecourses = []; + $hiddencourses = []; + + $now = time(); + $lastweek = $now - WEEKSECS; + $yesterday = $now - DAYSECS + 60; + $tomorrow = $now + DAYSECS; + + // Hidden course that started last week. + for ($i = 0; $i < $lastweekhidden; $i++) { + $generator->create_course(['visible' => false, 'startdate' => $lastweek]); + } + // Hidden course that started yesterday. + for ($i = 0; $i < $yesterdayhidden; $i++) { + $hiddencourses[] = $generator->create_course(['visible' => false, 'startdate' => $yesterday])->id; + } + // Hidden course that hasn't started yet. + for ($i = 0; $i < $tomorrowhidden; $i++) { + $generator->create_course(['visible' => false, 'startdate' => $tomorrow]); + } + if ($createvisible) { + // Visible course that already started. + $visiblecourses[] = $generator->create_course(['visible' => true, 'startdate' => $yesterday])->id; + // Visible course that hasn't started yet. + $visiblecourses[] = $generator->create_course(['visible' => true, 'startdate' => $tomorrow])->id; + } + $visibletotal = count($visiblecourses) + 1; + $coursetotal = $visibletotal + $lastweekhidden + $yesterdayhidden + $tomorrowhidden; + + // Check current courses have been created correctly. + $this->assertEquals($coursetotal, $DB->count_records('course')); + $this->assertEquals($visibletotal, $DB->count_records('course', ['visible' => 1])); + + $sink = $this->redirectEvents(); + + // Run the show started courses task. + ob_start(); + $task = new show_started_courses_task(); + $task->execute(); + ob_end_clean(); + + // Confirm the courses with yesterday as starting date are visible too. The rest should remain hidden. + $this->assertEquals($coursetotal, $DB->count_records('course')); + $courses = $DB->get_records('course', ['visible' => 1], '', 'id'); + $this->assertCount($visibletotal + $yesterdayhidden, $courses); + $expected = array_merge($hiddencourses, $visiblecourses); + $this->assertEquals(asort($expected), asort($courses)); + + // Check the started course event has been raised. + $events = $sink->get_events(); + $sink->close(); + $this->assertCount($yesterdayhidden, $events); + foreach ($events as $event) { + $this->assertInstanceOf('\\core\\event\\course_started', $event); + $this->assertArrayHasKey($event->courseid, array_flip($expected)); + } + } + + /** + * Data provider for test_show_started_courses. + * + * @return array + */ + public function get_courses_provider(): array { + return [ + 'No hidden courses' => [ + 'lastweek' => 0, + 'yesterday' => 0, + 'tomorrow' => 0, + ], + 'No hidden courses (without visible courses)' => [ + 'lastweek' => 0, + 'yesterday' => 0, + 'tomorrow' => 0, + 'createvisible' => false, + ], + 'Hidden courses with last week or tomorrow dates' => [ + 'lastweek' => 2, + 'yesterday' => 0, + 'tomorrow' => 2, + ], + 'One hidden course of each type (last week, yesterday and tomorrow)' => [ + 'lastweek' => 1, + 'yesterday' => 1, + 'tomorrow' => 1, + ], + 'Different hidden courses of each type' => [ + 'lastweek' => 2, + 'yesterday' => 3, + 'tomorrow' => 4, + ], + 'A couple of hidden courses of each type (without visible courses)' => [ + 'lastweek' => 2, + 'yesterday' => 2, + 'tomorrow' => 2, + 'createvisible' => false, + ], + 'Only a few hidden courses for yesterday' => [ + 'lastweek' => 0, + 'yesterday' => 5, + 'tomorrow' => 0, + ], + 'Only a few hidden courses for yesterday (without visible courses)' => [ + 'lastweek' => 0, + 'yesterday' => 5, + 'tomorrow' => 0, + 'createvisible' => false, + ], + ]; + } +} diff --git a/lib/upgrade.txt b/lib/upgrade.txt index d0fce8b9e77..33c0ebc00fc 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -35,6 +35,12 @@ information provided here is intended especially for developers. * Introduce a new public function \core_external\util::generate_token_name() * Legacy (and custom) Behat --skip-passed option has been removed completely. Please, use the standard --rerun option that provides exactly the same (execution of failed scenarios only). +* New scheduled tasks show_started_courses_task and hide_ended_courses_task that updates the course visibility when the current + day matches course start date/end date. They are disabled by default. Once they are enabled, only courses with start/end dates + near the past 24 hours will be checked. The visibility of these courses will only changed when their start/end dates are higher + than the current one, to avoid updating the course visibility early. +* New events course_started and course_ended have been created. For now, they are triggered when courses change automatically their + visibility through the new scheduled tasks to hide/show courses based on their start/end dates. === 4.2 === diff --git a/version.php b/version.php index b7bc78e76f8..b7944e76469 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2023062900.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2023062900.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.3dev (Build: 20230629)'; // Human-friendly version name