diff --git a/admin/cli/fix_orphaned_calendar_events.php b/admin/cli/fix_orphaned_calendar_events.php new file mode 100644 index 00000000000..304b2763c41 --- /dev/null +++ b/admin/cli/fix_orphaned_calendar_events.php @@ -0,0 +1,138 @@ +. + +/** + * Fix orphaned calendar events that were broken by MDL-67494. + * + * This script will look for all the calendar events which userids + * where broken by a wrong upgrade step, affecting to Moodle 3.9.5 + * and up. + * + * It performs checks to both: + * a) Detect if the site was affected (ran the wrong upgrade step). + * b) Look for orphaned calendar events, categorising them as: + * - standard: site / category / course / group / user events + * - subscription: events created via subscriptions. + * - action: normal action events, created to show common important dates. + * - override: user and group override events, particular, that some activities support. + * - custom: other events, not being any of the above, common or particular. + * By specifying it (--fix) try to recover as many broken events (missing userid) as + * possible. Standard, subscription, action, override events in core are fully supported but + * override or custom events should be fixed by each plugin as far as there isn't any standard + * API (plugin-wise) to launch a rebuild of the calendar events. + * + * @package core + * @copyright 2021 onwards Simey Lameze + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +define('CLI_SCRIPT', true); + +require_once(__DIR__ . '/../../config.php'); +require_once($CFG->libdir . "/clilib.php"); +require_once($CFG->libdir . '/db/upgradelib.php'); + +// Supported options. +$long = ['fix' => false, 'help' => false]; +$short = ['f' => 'fix', 'h' => 'help']; + +// CLI options. +[$options, $unrecognized] = cli_get_params($long, $short); + +if ($unrecognized) { + $unrecognized = implode("\n ", $unrecognized); + cli_error(get_string('cliunknowoption', 'admin', $unrecognized)); +} + +if ($options['help']) { + $help = <<version)) { + cli_error('Database is not yet installed.'); +} + +$admin = get_admin(); +if (!$admin) { + cli_error('Error: No admin account was found.'); +} + +if (moodle_needs_upgrading()) { + cli_error('Moodle upgrade pending, script execution suspended.'); +} + +// Do everything as admin by default. +\core\session\manager::set_user($admin); + +// Report current site status. +cli_heading('Checking the site status'); +$needsfix = upgrade_calendar_site_status(); + +// Report current calendar events status. +cli_heading('Checking the calendar events status'); +$info = upgrade_calendar_events_status(); +$hasbadevents = $info['total']->bad > 0 || $info['total']->bad != $info['other']->bad; +$needsfix = $needsfix || $hasbadevents; + +// If, selected, fix as many calendar events as possible. +if ($options['fix']) { + + // If the report has told us that the fix was not needed... ask for confirmation! + if (!$needsfix) { + cli_writeln("This site DOES NOT NEED to run the calendar events fix."); + $input = cli_input('Are you completely sure that you want to run the fix? (y/N)', 'N', ['y', 'Y', 'n', 'N']); + if (strtolower($input) != 'y') { + exit(0); + } + cli_writeln(""); + } + cli_heading('Fixing as many as possible calendar events'); + upgrade_calendar_events_fix_remaining($info); + // Report current (after fix) calendar events status. + cli_heading('Checking the calendar events status (after fix)'); + upgrade_calendar_events_status(); +} else if ($needsfix) { + // Fix option was not provided but problem events have been found. Notify the user and provide info how to fix these events. + cli_writeln("This site NEEDS to run the calendar events fix!"); + cli_writeln("To fix the calendar events, re-run this script with the --fix option."); +} diff --git a/calendar/tests/helpers.php b/calendar/tests/helpers.php index a1e2004cae6..5c373dc0a2e 100644 --- a/calendar/tests/helpers.php +++ b/calendar/tests/helpers.php @@ -66,6 +66,102 @@ function create_event($properties) { return $event->create($record); } +/** + * Helper function to create a x number of events for each event type. + * + * @param int $quantity The quantity of events to be created. + * @return array List of created events. + */ +function create_standard_events(int $quantity): array { + $types = ['site', 'category', 'course', 'group', 'user']; + + $events = []; + foreach ($types as $eventtype) { + // Create five events of each event type. + for ($i = 0; $i < $quantity; $i++) { + $events[] = create_event(['eventtype' => $eventtype]); + } + } + + return $events; +} + +/** + * Helper function to create an action event. + * + * @param array $data The event data. + * @return bool|calendar_event + */ +function create_action_event(array $data) { + global $CFG; + + require_once($CFG->dirroot . '/calendar/lib.php'); + + if (!isset($data['modulename']) || !isset($data['instance'])) { + throw new coding_exception('Module and instance should be specified when creating an action event.'); + } + + $isuseroverride = isset($data->priority) && $data->priority == CALENDAR_EVENT_USER_OVERRIDE_PRIORITY; + if ($isuseroverride) { + if (!in_array($data['modulename'], ['assign', 'lesson', 'quiz'])) { + throw new coding_exception('Only assign, lesson and quiz modules supports overrides'); + } + } + + $event = array_merge($data, [ + 'eventtype' => isset($data['eventtype']) ? $data['eventtype'] : 'open', + 'courseid' => isset($data['courseid']) ? $data['courseid'] : 0, + 'instance' => $data['instance'], + 'modulename' => $data['modulename'], + 'type' => CALENDAR_EVENT_TYPE_ACTION, + ]); + + return create_event($event); +} + +/** + * Helper function to create an user override calendar event. + * + * @param string $modulename The modulename. + * @param int $instanceid The instance id. + * @param int $userid The user id. + * @return calendar_event|false + */ +function create_user_override_event(string $modulename, int $instanceid, int $userid) { + if (!isset($userid)) { + throw new coding_exception('Must specify userid when creating a user override.'); + } + + return create_action_event([ + 'modulename' => $modulename, + 'instance' => $instanceid, + 'userid' => $userid, + 'priority' => CALENDAR_EVENT_USER_OVERRIDE_PRIORITY, + ]); +} + +/** + * Helper function to create an group override calendar event. + * + * @param string $modulename The modulename. + * @param int $instanceid The instance id. + * @param int $courseid The course id. + * @param int $groupid The group id. + * @return calendar_event|false + */ +function create_group_override_event(string $modulename, int $instanceid, int $courseid, int $groupid) { + if (!isset($groupid)) { + throw new coding_exception('Must specify groupid when creating a group override.'); + } + + return create_action_event([ + 'groupid' => $groupid, + 'courseid' => $courseid, + 'modulename' => $modulename, + 'instance' => $instanceid, + ]); +} + /** * A test factory that will create action events. * diff --git a/lib/classes/task/calendar_fix_orphaned_events.php b/lib/classes/task/calendar_fix_orphaned_events.php new file mode 100644 index 00000000000..e5bd38c2df1 --- /dev/null +++ b/lib/classes/task/calendar_fix_orphaned_events.php @@ -0,0 +1,73 @@ +. + +/** + * Adhoc task handling fixing of events that have had their userid lost. + * + * @package core + * @copyright 2021 onwards Simey Lameze + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\task; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class handling fixing of events that have had their userid lost. + * + * @package core + * @copyright 2021 onwards Simey Lameze + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class calendar_fix_orphaned_events extends adhoc_task { + + /** + * Run the task to recover the correct userid from the event. + * + * If the maximum number of records are updated, the task re-queues itself, + * as there may be more events to be fixed. + */ + public function execute() { + + // Check for problematic upgrade steps and fix orphaned records. + if ($this->update_events_wrong_userid_remaining()) { + // There are orphaned events to be fixed. + // The task will re-queue itself until all orphaned calendar events have been fixed. + \core\task\manager::queue_adhoc_task(new calendar_fix_orphaned_events()); + } + } + + /** + * Execute the recovery of events that have been set with userid to zero. + * + * @return bool Whether there are more events to be fixed. + */ + protected function update_events_wrong_userid_remaining(): bool { + global $CFG; + + require_once($CFG->libdir . '/db/upgradelib.php'); + + // Default the max runtime to 60 seconds, unless overridden in config.php. + $maxseconds = $CFG->calendareventsmaxseconds ?? MINSECS; + + // Orphaned events found, get those events so it can be recovered. + $eventsinfo = upgrade_calendar_events_status(); + + // Fix the orphaned events and returns if there are more events to be fixed. + return upgrade_calendar_events_fix_remaining($eventsinfo, true, $maxseconds); + } +} diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index f2c3c4e0bfa..d578aee1db5 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -2598,5 +2598,28 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2021052500.84); } + if ($oldversion < 2021052500.85) { + require_once($CFG->libdir . '/db/upgradelib.php'); + + // Check if this site has executed the problematic upgrade steps. + $needsfixing = upgrade_calendar_site_status(false); + + // Only queue the task if this site has been affected by the problematic upgrade step. + if ($needsfixing) { + + // Create adhoc task to search and recover orphaned calendar events. + $record = new \stdClass(); + $record->classname = '\core\task\calendar_fix_orphaned_events'; + + // Next run time based from nextruntime computation in \core\task\manager::queue_adhoc_task(). + $nextruntime = time() - 1; + $record->nextruntime = $nextruntime; + $DB->insert_record('task_adhoc', $record); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2021052500.85); + } + return true; } diff --git a/lib/db/upgradelib.php b/lib/db/upgradelib.php index 8e66a0f17a3..e502dd535ea 100644 --- a/lib/db/upgradelib.php +++ b/lib/db/upgradelib.php @@ -749,3 +749,528 @@ function upgrade_core_licenses() { set_config('sitedefaultlicense', reset($activelicenses)); } } + +/** + * Detects if the site may need to get the calendar events fixed or no. With optional output. + * + * @param bool $output true if the function must output information, false if not. + * @return bool true if the site needs to run the fixes, false if not. + */ +function upgrade_calendar_site_status(bool $output = true): bool { + global $DB; + + // List of upgrade steps where the bug happened. + $badsteps = [ + '3.9.5' => '2020061504.08', + '3.10.2' => '2020110901.09', + '3.11dev' => '2021022600.02', + '4.0dev' => '2021052500.65', + ]; + + // List of upgrade steps that ran the fixer. + $fixsteps = [ + '3.9.6+' => '2020061506.05', + '3.10.3+' => '2020110903.05', + '3.11dev' => '2021042100.02', + '4.0dev' => '2021052500.85', + ]; + + $targetsteps = array_merge(array_values($badsteps), array_values( $fixsteps)); + list($insql, $inparams) = $DB->get_in_or_equal($targetsteps); + $foundsteps = $DB->get_fieldset_sql(" + SELECT DISTINCT version + FROM {upgrade_log} + WHERE plugin = 'core' + AND version " . $insql . " + ORDER BY version", $inparams); + + // Analyse the found steps, to decide if the site needs upgrading or no. + $badfound = false; + $fixfound = false; + foreach ($foundsteps as $foundstep) { + $badfound = $badfound ?: array_search($foundstep, $badsteps, true); + $fixfound = $fixfound ?: array_search($foundstep, $fixsteps, true); + } + $needsfix = $badfound && !$fixfound; + + // Let's output some textual information if required to. + if ($output) { + mtrace(""); + if ($badfound) { + mtrace("This site has executed the problematic upgrade step {$badsteps[$badfound]} present in {$badfound}."); + } else { + mtrace("Problematic upgrade steps were NOT found, site should be safe."); + } + if ($fixfound) { + mtrace("This site has executed the fix upgrade step {$fixsteps[$fixfound]} present in {$fixfound}."); + } else { + mtrace("Fix upgrade steps were NOT found."); + } + mtrace(""); + if ($needsfix) { + mtrace("This site NEEDS to run the calendar events fix!"); + mtrace(''); + mtrace("You can use this CLI tool or upgrade to a version of Moodle that includes"); + mtrace("the fix and will be executed as part of the normal upgrade procedure."); + mtrace("The following versions or up are known candidates to upgrade to:"); + foreach ($fixsteps as $key => $value) { + mtrace(" - {$key}: {$value}"); + } + mtrace(""); + } + } + return $needsfix; +} + +/** + * Detects the calendar events needing to be fixed. With optional output. + * + * @param bool $output true if the function must output information, false if not. + * @return stdClass[] an array of event types (as keys) with total and bad counters, plus sql to retrieve them. + */ +function upgrade_calendar_events_status(bool $output = true): array { + global $DB; + + // Calculate the list of standard (core) activity plugins. + $plugins = core_plugin_manager::standard_plugins_list('mod'); + $coremodules = "modulename IN ('" . implode("', '", $plugins) . "')"; + + // Some query parts go here. + $brokenevents = "(userid = 0 AND (eventtype <> 'user' OR priority <> 0))"; // From the original bad upgrade step. + $standardevents = "(eventtype IN ('site', 'category', 'course', 'group', 'user') AND subscriptionid IS NULL)"; + $subscriptionevents = "(subscriptionid IS NOT NULL)"; + $overrideevents = "({$coremodules} AND priority IS NOT NULL)"; + $actionevents = "({$coremodules} AND instance > 0 and priority IS NULL)"; + $otherevents = "(NOT ({$standardevents} OR {$subscriptionevents} OR {$overrideevents} OR {$actionevents}))"; + + // Detailed query template. + $detailstemplate = " + SELECT ##group## AS groupname, COUNT(1) AS count + FROM {event} + WHERE ##groupconditions## + GROUP BY ##group##"; + + // Count total and potentially broken events. + $total = $DB->count_records_select('event', ''); + $totalbadsql = $brokenevents; + $totalbad = $DB->count_records_select('event', $totalbadsql); + + // Standard events. + $standard = $DB->count_records_select('event', $standardevents); + $standardbadsql = "{$brokenevents} AND {$standardevents}"; + $standardbad = $DB->count_records_select('event', $standardbadsql); + $standarddetails = $DB->get_records_sql( + str_replace( + ['##group##', '##groupconditions##'], + ['eventtype', $standardbadsql], + $detailstemplate + ) + ); + array_walk($standarddetails, function (&$rec) { + $rec = $rec->groupname . ': ' . $rec->count; + }); + $standarddetails = $standarddetails ? '(' . implode(', ', $standarddetails) . ')' : '- all good!'; + + // Subscription events. + $subscription = $DB->count_records_select('event', $subscriptionevents); + $subscriptionbadsql = "{$brokenevents} AND {$subscriptionevents}"; + $subscriptionbad = $DB->count_records_select('event', $subscriptionbadsql); + $subscriptiondetails = $DB->get_records_sql( + str_replace( + ['##group##', '##groupconditions##'], + ['eventtype', $subscriptionbadsql], + $detailstemplate + ) + ); + array_walk($subscriptiondetails, function (&$rec) { + $rec = $rec->groupname . ': ' . $rec->count; + }); + $subscriptiondetails = $subscriptiondetails ? '(' . implode(', ', $subscriptiondetails) . ')' : '- all good!'; + + // Override events. + $override = $DB->count_records_select('event', $overrideevents); + $overridebadsql = "{$brokenevents} AND {$overrideevents}"; + $overridebad = $DB->count_records_select('event', $overridebadsql); + $overridedetails = $DB->get_records_sql( + str_replace( + ['##group##', '##groupconditions##'], + ['modulename', $overridebadsql], + $detailstemplate + ) + ); + array_walk($overridedetails, function (&$rec) { + $rec = $rec->groupname . ': ' . $rec->count; + }); + $overridedetails = $overridedetails ? '(' . implode(', ', $overridedetails) . ')' : '- all good!'; + + // Action events. + $action = $DB->count_records_select('event', $actionevents); + $actionbadsql = "{$brokenevents} AND {$actionevents}"; + $actionbad = $DB->count_records_select('event', $actionbadsql); + $actiondetails = $DB->get_records_sql( + str_replace( + ['##group##', '##groupconditions##'], + ['modulename', $actionbadsql], + $detailstemplate + ) + ); + array_walk($actiondetails, function (&$rec) { + $rec = $rec->groupname . ': ' . $rec->count; + }); + $actiondetails = $actiondetails ? '(' . implode(', ', $actiondetails) . ')' : '- all good!'; + + // Other events. + $other = $DB->count_records_select('event', $otherevents); + $otherbadsql = "{$brokenevents} AND {$otherevents}"; + $otherbad = $DB->count_records_select('event', $otherbadsql); + $otherdetails = $DB->get_records_sql( + str_replace( + ['##group##', '##groupconditions##'], + ['COALESCE(component, modulename)', $otherbadsql], + $detailstemplate + ) + ); + array_walk($otherdetails, function (&$rec) { + $rec = ($rec->groupname ?: 'unknown') . ': ' . $rec->count; + }); + $otherdetails = $otherdetails ? '(' . implode(', ', $otherdetails) . ')' : '- all good!'; + + // Let's output some textual information if required to. + if ($output) { + mtrace(""); + mtrace("Totals: {$total} / {$totalbad} (total / wrong)"); + mtrace(" - standards events: {$standard} / {$standardbad} {$standarddetails}"); + mtrace(" - subscription events: {$subscription} / {$subscriptionbad} {$subscriptiondetails}"); + mtrace(" - override events: {$override} / {$overridebad} {$overridedetails}"); + mtrace(" - action events: {$action} / {$actionbad} {$actiondetails}"); + mtrace(" - other events: {$other} / {$otherbad} {$otherdetails}"); + mtrace(""); + } + + return [ + 'total' => (object)['count' => $total, 'bad' => $totalbad, 'sql' => $totalbadsql], + 'standard' => (object)['count' => $standard, 'bad' => $standardbad, 'sql' => $standardbadsql], + 'subscription' => (object)['count' => $subscription, 'bad' => $subscriptionbad, 'sql' => $subscriptionbadsql], + 'override' => (object)['count' => $override, 'bad' => $overridebad, 'sql' => $overridebadsql], + 'action' => (object)['count' => $action, 'bad' => $actionbad, 'sql' => $actionbadsql], + 'other' => (object)['count' => $other, 'bad' => $otherbad, 'sql' => $otherbadsql], + ]; +} + +/** + * Detects the calendar events needing to be fixed. With optional output. + * + * @param stdClass[] an array of event types (as keys) with total and bad counters, plus sql to retrieve them. + * @param bool $output true if the function must output information, false if not. + * @param int $maxseconds Number of seconds the function will run as max, with zero meaning no limit. + * @return bool true if the function has not finished fixing everything, false if it has finished. + */ +function upgrade_calendar_events_fix_remaining(array $info, bool $output = true, int $maxseconds = 0): bool { + global $DB; + + upgrade_calendar_events_mtrace('', $output); + + // Initial preparations. + $starttime = time(); + $endtime = $maxseconds ? ($starttime + $maxseconds) : 0; + + // No bad events, or all bad events are "other" events, finished. + if ($info['total']->bad == 0 || $info['total']->bad == $info['other']->bad) { + return false; + } + + // Let's fix overriden events first (they are the ones performing worse with the missing userid). + if ($info['override']->bad != 0) { + if (upgrade_calendar_override_events_fix($info['override'], $output, $endtime)) { + return true; // Not finished yet. + } + } + + // Let's fix the subscription events (like standard ones, but with the event_subscriptions table). + if ($info['subscription']->bad != 0) { + if (upgrade_calendar_subscription_events_fix($info['subscription'], $output, $endtime)) { + return true; // Not finished yet. + } + } + + // Let's fix the standard events (site, category, course, group). + if ($info['standard']->bad != 0) { + if (upgrade_calendar_standard_events_fix($info['standard'], $output, $endtime)) { + return true; // Not finished yet. + } + } + + // Let's fix the action events (all them are "general" ones, not user-specific in core). + if ($info['action']->bad != 0) { + if (upgrade_calendar_action_events_fix($info['action'], $output, $endtime)) { + return true; // Not finished yet. + } + } + + // Have arrived here, finished! + return false; +} + +/** + * Wrapper over mtrace() to allow a few more things to be specified. + * + * @param string $string string to output. + * @param bool $output true to perform the output, false to avoid it. + */ +function upgrade_calendar_events_mtrace(string $string, bool $output): void { + static $cols = 0; + + // No output, do nothing. + if (!$output) { + return; + } + + // Printing dots... let's output them slightly nicer. + if ($string === '.') { + $cols++; + // Up to 60 cols. + if ($cols < 60) { + mtrace($string, ''); + } else { + mtrace($string); + $cols = 0; + } + return; + } + + // Reset cols, have ended printing dots. + if ($cols) { + $cols = 0; + mtrace(''); + } + + // Normal output. + mtrace($string); +} + +/** + * Get a valid editing teacher for a given courseid + * + * @param int $courseid The course to look for editing teachers. + * @return int A user id of an editing teacher or, if missing, the admin userid. + */ +function upgrade_calendar_events_get_teacherid(int $courseid): int { + + if ($context = context_course::instance($courseid, IGNORE_MISSING)) { + if ($havemanage = get_users_by_capability($context, 'moodle/course:manageactivities', 'u.id')) { + return array_keys($havemanage)[0]; + } + } + return get_admin()->id; // Could not find a teacher, default to admin. +} + +/** + * Detects the calendar standard events needing to be fixed. With optional output. + * + * @param stdClass $info an object with total and bad counters, plus sql to retrieve them. + * @param bool $output true if the function must output information, false if not. + * @param int $endtime cutoff time when the process must stop (0 means no cutoff). + * @return bool true if the function has not finished fixing everything, false if it has finished. + */ +function upgrade_calendar_standard_events_fix(stdClass $info, bool $output = true, int $endtime = 0): bool { + global $DB; + + $return = false; // Let's assume the function is going to finish by default. + $status = "Finished!"; // To decide the message to be presented on return. + + upgrade_calendar_events_mtrace('Processing standard events', $output); + + $rs = $DB->get_recordset_sql(" + SELECT DISTINCT eventtype, courseid + FROM {event} + WHERE {$info->sql}"); + + foreach ($rs as $record) { + switch ($record->eventtype) { + case 'site': + case 'category': + // These are created by admin. + $DB->set_field('event', 'userid', get_admin()->id, ['eventtype' => $record->eventtype]); + break; + case 'course': + case 'group': + // These are created by course teacher. + $DB->set_field('event', 'userid', upgrade_calendar_events_get_teacherid($record->courseid), + ['eventtype' => $record->eventtype, 'courseid' => $record->courseid]); + break; + } + + // Cutoff time, let's exit. + if ($endtime && $endtime <= time()) { + $status = 'Remaining standard events pending'; + $return = true; // Not finished yet. + break; + } + upgrade_calendar_events_mtrace('.', $output); + } + $rs->close(); + upgrade_calendar_events_mtrace($status, $output); + upgrade_calendar_events_mtrace('', $output); + return $return; +} + +/** + * Detects the calendar subscription events needing to be fixed. With optional output. + * + * @param stdClass $info an object with total and bad counters, plus sql to retrieve them. + * @param bool $output true if the function must output information, false if not. + * @param int $endtime cutoff time when the process must stop (0 means no cutoff). + * @return bool true if the function has not finished fixing everything, false if it has finished. + */ +function upgrade_calendar_subscription_events_fix(stdClass $info, bool $output = true, int $endtime = 0): bool { + global $DB; + + $return = false; // Let's assume the function is going to finish by default. + $status = "Finished!"; // To decide the message to be presented on return. + + upgrade_calendar_events_mtrace('Processing subscription events', $output); + + $rs = $DB->get_recordset_sql(" + SELECT DISTINCT subscriptionid AS id + FROM {event} + WHERE {$info->sql}"); + + foreach ($rs as $subscription) { + // Subscriptions can be site or category level, let's put the admin as userid. + // (note that "user" subscription weren't deleted so there is nothing to recover with them. + $DB->set_field('event_subscriptions', 'userid', get_admin()->id, ['id' => $subscription->id]); + $DB->set_field('event', 'userid', get_admin()->id, ['subscriptionid' => $subscription->id]); + + // Cutoff time, let's exit. + if ($endtime && $endtime <= time()) { + $status = 'Remaining subscription events pending'; + $return = true; // Not finished yet. + break; + } + upgrade_calendar_events_mtrace('.', $output); + } + $rs->close(); + upgrade_calendar_events_mtrace($status, $output); + upgrade_calendar_events_mtrace('', $output); + return $return; +} + +/** + * Detects the calendar action events needing to be fixed. With optional output. + * + * @param stdClass $info an object with total and bad counters, plus sql to retrieve them. + * @param bool $output true if the function must output information, false if not. + * @param int $endtime cutoff time when the process must stop (0 means no cutoff). + * @return bool true if the function has not finished fixing everything, false if it has finished. + */ +function upgrade_calendar_action_events_fix(stdClass $info, bool $output = true, int $endtime = 0): bool { + global $DB; + + $return = false; // Let's assume the function is going to finish by default. + $status = "Finished!"; // To decide the message to be presented on return. + + upgrade_calendar_events_mtrace('Processing action events', $output); + + $rs = $DB->get_recordset_sql(" + SELECT DISTINCT modulename, instance, courseid + FROM {event} + WHERE {$info->sql}"); + + foreach ($rs as $record) { + // These are created by course teacher. + $DB->set_field('event', 'userid', upgrade_calendar_events_get_teacherid($record->courseid), + ['modulename' => $record->modulename, 'instance' => $record->instance, 'courseid' => $record->courseid]); + + // Cutoff time, let's exit. + if ($endtime && $endtime <= time()) { + $status = 'Remaining action events pending'; + $return = true; // Not finished yet. + break; + } + upgrade_calendar_events_mtrace('.', $output); + } + $rs->close(); + upgrade_calendar_events_mtrace($status, $output); + upgrade_calendar_events_mtrace('', $output); + return $return; +} + +/** + * Detects the calendar override events needing to be fixed. With optional output. + * + * @param stdClass $info an object with total and bad counters, plus sql to retrieve them. + * @param bool $output true if the function must output information, false if not. + * @param int $endtime cutoff time when the process must stop (0 means no cutoff). + * @return bool true if the function has not finished fixing everything, false if it has finished. + */ +function upgrade_calendar_override_events_fix(stdClass $info, bool $output = true, int $endtime = 0): bool { + global $CFG, $DB; + + include_once($CFG->dirroot. '/course/lib.php'); + include_once($CFG->dirroot. '/mod/assign/lib.php'); + include_once($CFG->dirroot. '/mod/assign/locallib.php'); + include_once($CFG->dirroot. '/mod/lesson/lib.php'); + include_once($CFG->dirroot. '/mod/lesson/locallib.php'); + include_once($CFG->dirroot. '/mod/quiz/lib.php'); + include_once($CFG->dirroot. '/mod/quiz/locallib.php'); + + $return = false; // Let's assume the function is going to finish by default. + $status = "Finished!"; // To decide the message to be presented on return. + + upgrade_calendar_events_mtrace('Processing override events', $output); + + $rs = $DB->get_recordset_sql(" + SELECT DISTINCT modulename, instance + FROM {event} + WHERE {$info->sql}"); + + foreach ($rs as $module) { + // Remove all the records from the events table for the module. + $DB->delete_records('event', ['modulename' => $module->modulename, 'instance' => $module->instance]); + + // Get the activity record. + if (!$activityrecord = $DB->get_record($module->modulename, ['id' => $module->instance])) { + // Orphaned calendar event (activity doesn't exists), skip. + continue; + } + + // Let's rebuild it by calling to each module API. + switch ($module->modulename) { + case 'assign'; + if (function_exists('assign_prepare_update_events')) { + assign_prepare_update_events($activityrecord); + } + break; + case 'lesson': + if (function_exists('lesson_update_events')) { + lesson_update_events($activityrecord); + } + break; + case 'quiz': + if (function_exists('quiz_update_events')) { + quiz_update_events($activityrecord); + } + break; + } + + // Sometimes, some (group) overrides are created without userid, when that happens, they deserve + // some user (teacher or admin). This doesn't affect to groups calendar events behaviour, + // but allows counters to detect already processed group overrides and makes things + // consistent. + $DB->set_field_select('event', 'userid', upgrade_calendar_events_get_teacherid($activityrecord->course), + 'modulename = ? AND instance = ? and priority != 0 and userid = 0', + ['modulename' => $module->modulename, 'instance' => $module->instance]); + + // Cutoff time, let's exit. + if ($endtime && $endtime <= time()) { + $status = 'Remaining override events pending'; + $return = true; // Not finished yet. + break; + } + upgrade_calendar_events_mtrace('.', $output); + } + $rs->close(); + upgrade_calendar_events_mtrace($status, $output); + upgrade_calendar_events_mtrace('', $output); + return $return; +} diff --git a/lib/tests/upgradelib_test.php b/lib/tests/upgradelib_test.php index 5298ad5a888..b9769af0d04 100644 --- a/lib/tests/upgradelib_test.php +++ b/lib/tests/upgradelib_test.php @@ -28,6 +28,7 @@ defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->libdir.'/upgradelib.php'); require_once($CFG->libdir.'/db/upgradelib.php'); +require_once($CFG->dirroot . '/calendar/tests/helpers.php'); /** * Tests various classes and functions in upgradelib.php library. @@ -1129,4 +1130,364 @@ class core_upgradelib_testcase extends advanced_testcase { $actualshortnames = $DB->get_records_menu('license', null, '', 'id, shortname'); $this->assertNotContains($deletedcorelicenseshortname, $actualshortnames); } + + /** + * Execute same problematic query from upgrade step. + * + * @return bool + */ + public function run_upgrade_step_query() { + global $DB; + + return $DB->execute("UPDATE {event} SET userid = 0 WHERE eventtype <> 'user' OR priority <> 0"); + } + + /** + * Test the functionality of upgrade_calendar_events_status() function. + */ + public function test_upgrade_calendar_events_status() { + + $this->resetAfterTest(); + $this->setAdminUser(); + + $events = create_standard_events(5); + $eventscount = count($events); + + // Run same DB query as the problematic upgrade step. + $this->run_upgrade_step_query(); + + // Get the events info. + $status = upgrade_calendar_events_status(false); + + // Total events. + $expected = [ + 'total' => (object)[ + 'count' => $eventscount, + 'bad' => $eventscount - 5, // Event count excluding user events. + ], + 'standard' => (object)[ + 'count' => $eventscount, + 'bad' => $eventscount - 5, // Event count excluding user events. + ], + ]; + + $this->assertEquals($expected['standard']->count, $status['standard']->count); + $this->assertEquals($expected['standard']->bad, $status['standard']->bad); + $this->assertEquals($expected['total']->count, $status['total']->count); + $this->assertEquals($expected['total']->bad, $status['total']->bad); + } + + /** + * Test the functionality of upgrade_calendar_events_get_teacherid() function. + */ + public function test_upgrade_calendar_events_get_teacherid() { + global $DB; + + $this->resetAfterTest(); + + // Create a new course and enrol a user as editing teacher. + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + $teacher = $generator->create_and_enrol($course, 'editingteacher'); + + // There's a teacher enrolled in the course, return its user id. + $userid = upgrade_calendar_events_get_teacherid($course->id); + + // It should return the enrolled teacher by default. + $this->assertEquals($teacher->id, $userid); + + // Un-enrol teacher from course. + $instance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'manual']); + enrol_get_plugin('manual')->unenrol_user($instance, $teacher->id); + + // Since there are no teachers enrolled in the course, fallback to admin user id. + $admin = get_admin(); + $userid = upgrade_calendar_events_get_teacherid($course->id); + $this->assertEquals($admin->id, $userid); + } + + /** + * Test the functionality of upgrade_calendar_standard_events_fix() function. + */ + public function test_upgrade_calendar_standard_events_fix() { + + $this->resetAfterTest(); + $this->setAdminUser(); + + $events = create_standard_events(5); + $eventscount = count($events); + + // Get the events info. + $info = upgrade_calendar_events_status(false); + + // There should be no standard events to be fixed. + $this->assertEquals(0, $info['standard']->bad); + + // No events to be fixed, should return false. + $this->assertFalse(upgrade_calendar_standard_events_fix($info['standard'], false)); + + // Run same problematic DB query. + $this->run_upgrade_step_query(); + + // Get the events info. + $info = upgrade_calendar_events_status(false); + + // There should be 20 events to be fixed (five from each type except user). + $this->assertEquals($eventscount - 5, $info['standard']->bad); + + // Test the function runtime, passing -1 as end time. + // It should not be able to fix all events so fast, so some events should remain to be fixed in the next run. + $result = upgrade_calendar_standard_events_fix($info['standard'], false, -1); + $this->assertNotFalse($result); + + // Call the function again, this time it will run until all events have been fixed. + $this->assertFalse(upgrade_calendar_standard_events_fix($info['standard'], false)); + + // Get the events info again. + $info = upgrade_calendar_events_status(false); + + // All standard events should have been recovered. + // There should be no standard events flagged to be fixed. + $this->assertEquals(0, $info['standard']->bad); + } + + /** + * Test the functionality of upgrade_calendar_subscription_events_fix() function. + */ + public function test_upgrade_calendar_subscription_events_fix() { + global $CFG, $DB; + + require_once($CFG->dirroot . '/calendar/lib.php'); + require_once($CFG->dirroot . '/lib/bennu/bennu.inc.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + // Create event subscription. + $subscription = new stdClass; + $subscription->name = 'Repeated events'; + $subscription->importfrom = CALENDAR_IMPORT_FROM_FILE; + $subscription->eventtype = 'site'; + $id = calendar_add_subscription($subscription); + + // Get repeated events ICS file. + $calendar = file_get_contents($CFG->dirroot . '/lib/tests/fixtures/repeated_events.ics'); + $ical = new iCalendar(); + $ical->unserialize($calendar); + + // Import subscription events. + calendar_import_icalendar_events($ical, null, $id); + + // Subscription should have added 18 events. + $eventscount = $DB->count_records('event'); + + // Get the events info. + $info = upgrade_calendar_events_status(false); + + // There should be no subscription events to be fixed at this point. + $this->assertEquals(0, $info['subscription']->bad); + + // No events to be fixed, should return false. + $this->assertFalse(upgrade_calendar_subscription_events_fix($info['subscription'], false)); + + // Run same problematic DB query. + $this->run_upgrade_step_query(); + + // Get the events info and assert total number of events is correct. + $info = upgrade_calendar_events_status(false); + $subscriptioninfo = $info['subscription']; + + $this->assertEquals($eventscount, $subscriptioninfo->count); + + // Since we have added our subscription as site, all sub events have been affected. + $this->assertEquals($eventscount, $subscriptioninfo->bad); + + // Test the function runtime, passing -1 as end time. + // It should not be able to fix all events so fast, so some events should remain to be fixed in the next run. + $result = upgrade_calendar_subscription_events_fix($subscriptioninfo, false, -1); + $this->assertNotFalse($result); + + // Call the function again, this time it will run until all events have been fixed. + $this->assertFalse(upgrade_calendar_subscription_events_fix($subscriptioninfo, false)); + + // Get the events info again. + $info = upgrade_calendar_events_status(false); + + // All standard events should have been recovered. + // There should be no standard events flagged to be fixed. + $this->assertEquals(0, $info['subscription']->bad); + } + + /** + * Test the functionality of upgrade_calendar_action_events_fix() function. + */ + public function test_upgrade_calendar_action_events_fix() { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + // Create a new course and a choice activity. + $course = $this->getDataGenerator()->create_course(); + $choice = $this->getDataGenerator()->create_module('choice', ['course' => $course->id]); + + // Create some action events. + create_action_event(['courseid' => $course->id, 'modulename' => 'choice', 'instance' => $choice->id, + 'eventtype' => CHOICE_EVENT_TYPE_OPEN]); + create_action_event(['courseid' => $course->id, 'modulename' => 'choice', 'instance' => $choice->id, + 'eventtype' => CHOICE_EVENT_TYPE_CLOSE]); + + $eventscount = $DB->count_records('event'); + + // Get the events info. + $info = upgrade_calendar_events_status(false); + $actioninfo = $info['action']; + + // There should be no standard events to be fixed. + $this->assertEquals(0, $actioninfo->bad); + + // No events to be fixed, should return false. + $this->assertFalse(upgrade_calendar_action_events_fix($actioninfo, false)); + + // Run same problematic DB query. + $this->run_upgrade_step_query(); + + // Get the events info. + $info = upgrade_calendar_events_status(false); + $actioninfo = $info['action']; + + // There should be 2 events to be fixed. + $this->assertEquals($eventscount, $actioninfo->bad); + + // Test the function runtime, passing -1 as end time. + // It should not be able to fix all events so fast, so some events should remain to be fixed in the next run. + $this->assertNotFalse(upgrade_calendar_action_events_fix($actioninfo, false, -1)); + + // Call the function again, this time it will run until all events have been fixed. + $this->assertFalse(upgrade_calendar_action_events_fix($actioninfo, false)); + + // Get the events info again. + $info = upgrade_calendar_events_status(false); + + // All standard events should have been recovered. + // There should be no standard events flagged to be fixed. + $this->assertEquals(0, $info['action']->bad); + } + + /** + * Test the user override part of upgrade_calendar_override_events_fix() function. + */ + public function test_upgrade_calendar_user_override_events_fix() { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $generator = $this->getDataGenerator(); + + // Create a new course. + $course = $generator->create_course(); + + // Create few users and enrol as students. + $student1 = $generator->create_and_enrol($course, 'student'); + $student2 = $generator->create_and_enrol($course, 'student'); + $student3 = $generator->create_and_enrol($course, 'student'); + + // Create some activities and some override events. + foreach (['assign', 'lesson', 'quiz'] as $modulename) { + $instance = $generator->create_module($modulename, ['course' => $course->id]); + create_user_override_event($modulename, $instance->id, $student1->id); + create_user_override_event($modulename, $instance->id, $student2->id); + create_user_override_event($modulename, $instance->id, $student3->id); + } + + // There should be 9 override events to be fixed (three from each module). + $eventscount = $DB->count_records('event'); + $this->assertEquals(9, $eventscount); + + // Get the events info. + $info = upgrade_calendar_events_status(false); + $overrideinfo = $info['override']; + + // There should be no standard events to be fixed. + $this->assertEquals(0, $overrideinfo->bad); + + // No events to be fixed, should return false. + $this->assertFalse(upgrade_calendar_override_events_fix($overrideinfo, false)); + + // Run same problematic DB query. + $this->run_upgrade_step_query(); + + // Get the events info. + $info = upgrade_calendar_events_status(false); + $overrideinfo = $info['override']; + + // There should be 9 events to be fixed (three from each module). + $this->assertEquals($eventscount, $overrideinfo->bad); + + // Call the function again, this time it will run until all events have been fixed. + $this->assertFalse(upgrade_calendar_override_events_fix($overrideinfo, false)); + + // Get the events info again. + $info = upgrade_calendar_events_status(false); + + // All standard events should have been recovered. + // There should be no standard events flagged to be fixed. + $this->assertEquals(0, $info['override']->bad); + } + + /** + * Test the group override part of upgrade_calendar_override_events_fix() function. + */ + public function test_upgrade_calendar_group_override_events_fix() { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $generator = $this->getDataGenerator(); + + // Create a new course and few groups. + $course = $generator->create_course(); + $group1 = $generator->create_group(['courseid' => $course->id]); + $group2 = $generator->create_group(['courseid' => $course->id]); + $group3 = $generator->create_group(['courseid' => $course->id]); + + // Create some activities and some override events. + foreach (['assign', 'lesson', 'quiz'] as $modulename) { + $instance = $generator->create_module($modulename, ['course' => $course->id]); + create_group_override_event($modulename, $instance->id, $course->id, $group1->id); + create_group_override_event($modulename, $instance->id, $course->id, $group2->id); + create_group_override_event($modulename, $instance->id, $course->id, $group3->id); + } + + // There should be 9 override events to be fixed (three from each module). + $eventscount = $DB->count_records('event'); + $this->assertEquals(9, $eventscount); + + // Get the events info. + $info = upgrade_calendar_events_status(false); + + // We classify group overrides as action events since they do not record the userid. + $groupoverrideinfo = $info['action']; + + // There should be no events to be fixed. + $this->assertEquals(0, $groupoverrideinfo->bad); + + // No events to be fixed, should return false. + $this->assertFalse(upgrade_calendar_action_events_fix($groupoverrideinfo, false)); + + // Run same problematic DB query. + $this->run_upgrade_step_query(); + + // Get the events info. + $info = upgrade_calendar_events_status(false); + $this->assertEquals(9, $info['action']->bad); + + // Call the function again, this time it will run until all events have been fixed. + $this->assertFalse(upgrade_calendar_action_events_fix($info['action'], false)); + + // Since group override events do not set userid, these events should not be flagged to be fixed. + $this->assertEquals(0, $groupoverrideinfo->bad); + } } diff --git a/version.php b/version.php index 84484f371f2..53b52b7d6c9 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2021052500.84; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2021052500.85; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.0dev (Build: 20210420)'; // Human-friendly version name