From d83d9aac591cd3c270dcac626a509bcaffbb4365 Mon Sep 17 00:00:00 2001 From: Alex Yeung Date: Thu, 21 Nov 2024 10:25:58 +0000 Subject: [PATCH] CTP-4066 SORA extenson for new student enrolment in course --- classes/cachemanager.php | 20 ++- classes/extension/ec.php | 10 +- classes/extension/extension.php | 4 +- classes/extension/iextension.php | 4 +- classes/extension/sora.php | 18 ++- classes/extension/sora_queue_processor.php | 6 +- classes/extensionmanager.php | 73 +++++++-- classes/manager.php | 94 +++++++++--- classes/observer.php | 5 +- classes/output/pushrecord.php | 2 +- classes/task/process_aws_sora_updates.php | 3 +- .../task/process_extensions_new_enrolment.php | 142 ++++++++++++++++++ ...php => process_extensions_new_mapping.php} | 14 +- classes/taskmanager.php | 28 +++- classes/user_enrolment_callbacks.php | 69 +++++++++ db/caches.php | 5 + db/hooks.php | 33 ++++ db/install.xml | 17 ++- db/upgrade.php | 27 ++++ lang/en/local_sitsgradepush.php | 7 +- tests/extension/extension_test.php | 124 +++++++++++++-- tests/manager_test.php | 8 +- version.php | 2 +- 23 files changed, 625 insertions(+), 90 deletions(-) create mode 100644 classes/task/process_extensions_new_enrolment.php rename classes/task/{process_extensions.php => process_extensions_new_mapping.php} (75%) create mode 100644 classes/user_enrolment_callbacks.php create mode 100644 db/hooks.php diff --git a/classes/cachemanager.php b/classes/cachemanager.php index 41334ed..8692c30 100644 --- a/classes/cachemanager.php +++ b/classes/cachemanager.php @@ -39,6 +39,9 @@ class cachemanager { /** @var string Cache area for storing marking schemes.*/ const CACHE_AREA_MARKINGSCHEMES = 'markingschemes'; + /** @var string Cache area for storing mapping and mab information.*/ + const CACHE_AREA_MAPPING_MAB_INFO = 'mappingmabinfo'; + /** * Get cache. * @@ -49,13 +52,18 @@ class cachemanager { */ public static function get_cache(string $area, string $key) { // Check if cache exists or expired. - $cache = cache::make('local_sitsgradepush', $area)->get($key); - // Expire key. - $expires = 'expires_' . $key; - if (empty($cache) || empty($expires) || time() >= $expires) { + $cache = cache::make('local_sitsgradepush', $area); + $cachevalue = $cache->get($key); + $expires = $cache->get('expires_' . $key); + + if (empty($cachevalue) || empty($expires) || time() >= $expires) { + if ($expires && time() >= $expires) { + // Cache expired, delete it. + self::purge_cache($area, $key); + } return null; } else { - return $cache; + return $cachevalue; } } @@ -71,7 +79,7 @@ public static function get_cache(string $area, string $key) { public static function set_cache(string $area, string $key, mixed $value, int $expiresafter): void { $cache = cache::make('local_sitsgradepush', $area); $cache->set($key, $value); - $cache->set('expires_' . $key, $expiresafter); + $cache->set('expires_' . $key, time() + $expiresafter); } /** diff --git a/classes/extension/ec.php b/classes/extension/ec.php index edffff7..12151a2 100644 --- a/classes/extension/ec.php +++ b/classes/extension/ec.php @@ -46,12 +46,12 @@ public function get_new_deadline(): string { /** * Process the extension. + * + * @param array $mappings + * @throws \dml_exception */ - public function process_extension(): void { - // Get all mappings for the SITS assessment. - // We only allow one mapping per SITS assessment for now. - $mappings = $this->get_mappings_by_mab($this->get_mab_identifier()); - + public function process_extension(array $mappings): void { + // Exit if empty mappings. if (empty($mappings)) { return; } diff --git a/classes/extension/extension.php b/classes/extension/extension.php index 56179ff..8959641 100644 --- a/classes/extension/extension.php +++ b/classes/extension/extension.php @@ -93,7 +93,7 @@ public static function is_module_supported(?string $module): bool { * @throws \dml_exception * @throws \moodle_exception */ - protected function get_mappings_by_mab(string $mabidentifier): array { + public function get_mappings_by_mab(string $mabidentifier): array { global $DB; // Extract the map code and MAB sequence number from the MAB identifier. @@ -125,7 +125,7 @@ protected function get_mappings_by_mab(string $mabidentifier): array { * @return array * @throws \dml_exception|\coding_exception */ - protected function get_mappings_by_userid(int $userid): array { + public function get_mappings_by_userid(int $userid): array { global $DB; // Find all enrolled courses for the student. diff --git a/classes/extension/iextension.php b/classes/extension/iextension.php index c494246..c3f6c55 100644 --- a/classes/extension/iextension.php +++ b/classes/extension/iextension.php @@ -27,6 +27,8 @@ interface iextension { /** * Process the extension. + * + * @param array $mappings SITS component mappings. */ - public function process_extension(): void; + public function process_extension(array $mappings): void; } diff --git a/classes/extension/sora.php b/classes/extension/sora.php index c44d89a..8c73370 100644 --- a/classes/extension/sora.php +++ b/classes/extension/sora.php @@ -182,20 +182,24 @@ public function set_properties_from_get_students_api(array $student): void { /** * Process the extension. * + * @param array $mappings + * * @return void * @throws \coding_exception - * @throws \dml_exception + * @throws \dml_exception|\moodle_exception */ - public function process_extension(): void { + public function process_extension(array $mappings): void { + // Empty mappings, exit early. + if (empty($mappings)) { + return; + } + if (!$this->dataisset) { throw new \coding_exception('error:extensiondataisnotset', 'local_sitsgradepush'); } - // Get all mappings for the student. - $mappings = $this->get_mappings_by_userid($this->get_userid()); - - // No mappings found. - if (empty($mappings)) { + // Exit if SORA extra assessment duration and rest duration are both 0. + if ($this->extraduration == 0 && $this->restduration == 0) { return; } diff --git a/classes/extension/sora_queue_processor.php b/classes/extension/sora_queue_processor.php index 3b84c6b..d725b31 100644 --- a/classes/extension/sora_queue_processor.php +++ b/classes/extension/sora_queue_processor.php @@ -16,6 +16,8 @@ namespace local_sitsgradepush\extension; +use local_sitsgradepush\manager; + /** * SORA queue processor. * @@ -47,6 +49,8 @@ protected function get_queue_url(): string { protected function process_message(array $messagebody): void { $sora = new sora(); $sora->set_properties_from_aws_message($messagebody['Message']); - $sora->process_extension(); + // Get all mappings for the student. + $mappings = $sora->get_mappings_by_userid($sora->get_userid()); + $sora->process_extension($mappings); } } diff --git a/classes/extensionmanager.php b/classes/extensionmanager.php index d1bb6ea..d775f3c 100644 --- a/classes/extensionmanager.php +++ b/classes/extensionmanager.php @@ -17,6 +17,7 @@ namespace local_sitsgradepush; use local_sitsgradepush\extension\sora; +use local_sitsgradepush\task\process_extensions_new_enrolment; /** * Manager class for extension related operations. @@ -31,37 +32,77 @@ class extensionmanager { /** * Update SORA extension for students in a mapping. * - * @param int $mapid + * @param \stdClass $mapping Assessment component mapping ID. + * @param array $students Students data from the SITS get students API. * @return void * @throws \dml_exception */ - public static function update_sora_for_mapping(int $mapid): void { + public static function update_sora_for_mapping(\stdClass $mapping, array $students): void { try { - // Find the SITS assessment component. - $manager = manager::get_manager(); - $mab = $manager->get_mab_by_mapping_id($mapid); - - // Throw exception if the SITS assessment component is not found. - if (!$mab) { - throw new \moodle_exception('error:mab_not_found', 'local_sitsgradepush', '', $mapid); + if ($mapping->enableextension !== '1') { + throw new \moodle_exception('error:extension_not_enabled_for_mapping', 'local_sitsgradepush', '', $mapping->id); } - // Get students information for that assessment component. - $students = $manager->get_students_from_sits($mab); - - // If no students found, nothing to do. + // If no students returned from SITS, nothing to do. if (empty($students)) { return; } - // Process SORA extension for each student. + // Process SORA extension for each student or the specified student if user id is provided. foreach ($students as $student) { $sora = new sora(); $sora->set_properties_from_get_students_api($student); - $sora->process_extension(); + $sora->process_extension([$mapping]); } } catch (\Exception $e) { - logger::log($e->getMessage(), null, "Mapping ID: $mapid"); + logger::log($e->getMessage(), null, "Mapping ID: $mapping->id"); } } + + /** + * Check if the extension is enabled. + * + * @return bool + * @throws \dml_exception + */ + public static function is_extension_enabled(): bool { + return get_config('local_sitsgradepush', 'extension_enabled') == '1'; + } + + /** + * Check if the user is enrolling a gradable role. + * + * @param int $roleid Role ID. + * @return bool + */ + public static function user_is_enrolling_a_gradable_role(int $roleid): bool { + global $CFG; + + $gradebookroles = !empty($CFG->gradebookroles) ? explode(',', $CFG->gradebookroles) : []; + + return in_array($roleid, $gradebookroles); + } + + /** + * Get the user enrolment events stored for a course. + * + * @param int $courseid Course ID. + * @return array + * @throws \dml_exception + */ + public static function get_user_enrolment_events(int $courseid): array { + global $DB; + $sql = "SELECT ue.* + FROM {local_sitsgradepush_enrol} ue + WHERE ue.courseid = :courseid AND ue.attempts < :maxattempts"; + + return $DB->get_records_sql( + $sql, + [ + 'courseid' => $courseid, + 'maxattempts' => process_extensions_new_enrolment::MAX_ATTEMPTS, + ], + limitnum: process_extensions_new_enrolment::BATCH_LIMIT + ); + } } diff --git a/classes/manager.php b/classes/manager.php index 37e9511..829dbf7 100644 --- a/classes/manager.php +++ b/classes/manager.php @@ -17,10 +17,8 @@ namespace local_sitsgradepush; use context_course; -use core_component; use core_course\customfield\course_handler; use DirectoryIterator; -use grade_tree; use local_sitsgradepush\api\client_factory; use local_sitsgradepush\api\iclient; use local_sitsgradepush\api\irequest; @@ -181,7 +179,7 @@ public function fetch_component_grades_from_sits(array $modocc): void { $this->check_response($response, $request); // Set cache expiry to 1 hour. - cachemanager::set_cache(cachemanager::CACHE_AREA_COMPONENTGRADES, $key, $response, 3600); + cachemanager::set_cache(cachemanager::CACHE_AREA_COMPONENTGRADES, $key, $response, HOURSECS); // Save component grades to DB. $this->save_component_grades($response); @@ -215,7 +213,7 @@ public function fetch_marking_scheme_from_sits() { $this->check_response($response, $request); // Set cache expiry to 1 hour. - cachemanager::set_cache(cachemanager::CACHE_AREA_MARKINGSCHEMES, $key, $response, 3600); + cachemanager::set_cache(cachemanager::CACHE_AREA_MARKINGSCHEMES, $key, $response, HOURSECS); return $response; } catch (\moodle_exception $e) { @@ -489,7 +487,7 @@ public function save_assessment_mapping(\stdClass $data): int|bool { } $record->componentgradeid = $data->componentgradeid; $record->reassessment = $data->reassessment; - $record->enableextension = (get_config('local_sitsgradepush', 'extension_enabled') && + $record->enableextension = (extensionmanager::is_extension_enabled() && (isset($record->moduletype) && extension::is_module_supported($record->moduletype))) ? 1 : 0; $record->timecreated = time(); $record->timemodified = time(); @@ -609,12 +607,13 @@ public function get_student_from_sits(\stdClass $componentgrade, int $userid): m * Get students for a grade component from SITS. * * @param \stdClass $componentgrade + * @param bool $refresh Refresh data from SITS. * @return \cache_application|\cache_session|\cache_store|mixed * @throws \coding_exception * @throws \dml_exception * @throws \moodle_exception */ - public function get_students_from_sits(\stdClass $componentgrade): mixed { + public function get_students_from_sits(\stdClass $componentgrade, bool $refresh = false): mixed { // Stutalk Direct is not supported currently. if ($this->apiclient->get_client_name() == 'Stutalk Direct') { throw new \moodle_exception( @@ -630,13 +629,16 @@ public function get_students_from_sits(\stdClass $componentgrade): mixed { return tests_data_provider::get_behat_test_students_response($componentgrade->mapcode, $componentgrade->mabseq); } - // Try to get cache first. $key = implode('_', [cachemanager::CACHE_AREA_STUDENTSPR, $componentgrade->mapcode, $componentgrade->mabseq]); - $students = cachemanager::get_cache(cachemanager::CACHE_AREA_STUDENTSPR, $key); - - // Cache found, return students. - if (!empty($students)) { - return $students; + if ($refresh) { + // Clear cache. + cachemanager::purge_cache(cachemanager::CACHE_AREA_STUDENTSPR, $key); + } else { + // Try to get cache first. + $students = cachemanager::get_cache(cachemanager::CACHE_AREA_STUDENTSPR, $key); + if (!empty($students)) { + return $students; + } } // Build required data. @@ -654,7 +656,7 @@ public function get_students_from_sits(\stdClass $componentgrade): mixed { cachemanager::CACHE_AREA_STUDENTSPR, $key, $result, - strtotime('+30 days'), + DAYSECS * 30 ); } @@ -885,16 +887,47 @@ public function get_transfer_logs(int $assessmentmappingid, int $userid, ?string * @param int $id Assessment mapping ID. * * @return false|mixed - * @throws \dml_exception + * @throws \dml_exception|\coding_exception */ - public function get_mab_by_mapping_id(int $id): mixed { + public function get_mab_and_map_info_by_mapping_id(int $id): mixed { global $DB; - $sql = "SELECT cg.* + + // Try to get the cache first. + $key = 'map_mab_info_' . $id; + $cache = cachemanager::get_cache(cachemanager::CACHE_AREA_MAPPING_MAB_INFO, $key); + if (!empty($cache)) { + return $cache; + } + + // Define the SQL query for retrieving the information. + $sql = "SELECT + am.id, + am.courseid, + am.sourceid, + am.sourcetype, + am.moduletype, + am.reassessment, + am.enableextension, + cg.id as mabid, + cg.mapcode, + cg.mabseq FROM {" . self::TABLE_COMPONENT_GRADE . "} cg - JOIN {" . self::TABLE_ASSESSMENT_MAPPING . "} am ON cg.id = am.componentgradeid + INNER JOIN {" . self::TABLE_ASSESSMENT_MAPPING . "} am + ON cg.id = am.componentgradeid WHERE am.id = :id"; - return $DB->get_record_sql($sql, ['id' => $id]); + // Fetch the record from the database. + $mapmabinfo = $DB->get_record_sql($sql, ['id' => $id]); + if (!empty($mapmabinfo)) { + // Set the cache. + cachemanager::set_cache( + cachemanager::CACHE_AREA_MAPPING_MAB_INFO, + $key, + $mapmabinfo, + DAYSECS * 30 + ); + } + return $mapmabinfo; } /** @@ -1444,6 +1477,31 @@ public function get_all_summative_grade_items(int $courseid): array { return $results; } + /** + * Get assessment mappings by course id. + * + * @param int $courseid + * @param bool $extensionenabledonly + * @return array + * @throws \dml_exception + */ + public function get_assessment_mappings_by_courseid(int $courseid, bool $extensionenabledonly = false): array { + global $DB; + + if ($extensionenabledonly) { + // Get mappings that are enabled for extension only. + $extensionenabledonlysql = 'AND am.enableextension = 1'; + } else { + $extensionenabledonlysql = ''; + } + $sql = "SELECT am.*, cg.mapcode, cg.mabseq + FROM {".self::TABLE_ASSESSMENT_MAPPING."} am + JOIN {".self::TABLE_COMPONENT_GRADE."} cg ON am.componentgradeid = cg.id + WHERE courseid = :courseid $extensionenabledonlysql"; + + return $DB->get_records_sql($sql, ['courseid' => $courseid]); + } + /** * Delete assessment mapping. * diff --git a/classes/observer.php b/classes/observer.php index 6bfd183..46a6758 100644 --- a/classes/observer.php +++ b/classes/observer.php @@ -15,6 +15,7 @@ // along with Moodle. If not, see . use local_sitsgradepush\cachemanager; +use local_sitsgradepush\extensionmanager; use local_sitsgradepush\manager; use local_sitsgradepush\taskmanager; @@ -96,8 +97,8 @@ public static function assessment_mapped(\local_sitsgradepush\event\assessment_m cachemanager::purge_cache(cachemanager::CACHE_AREA_STUDENTSPR, $key); // Add the process extensions adhoc task if process extensions is enabled. - if (get_config('local_sitsgradepush', 'extension_enabled')) { - taskmanager::add_process_extensions_adhoc_task($data['other']['mappingid']); + if (extensionmanager::is_extension_enabled()) { + taskmanager::add_process_extensions_for_new_mapping_adhoc_task($data['other']['mappingid']); } } } diff --git a/classes/output/pushrecord.php b/classes/output/pushrecord.php index 8a1281c..ececd50 100644 --- a/classes/output/pushrecord.php +++ b/classes/output/pushrecord.php @@ -262,7 +262,7 @@ protected function set_transfer_records(int $assessmentmappingid, int $studentid // The Easikit Get Student API will remove the students whose marks had been transferred successfully. // Find the assessment component - for that transfer log, // so that we can display the transfer status of mark transfer in the corresponding assessment component mapping. - $mab = $this->manager->get_mab_by_mapping_id($assessmentmappingid); + $mab = $this->manager->get_mab_and_map_info_by_mapping_id($assessmentmappingid); if (!empty($mab)) { $this->componentgrade = $mab->mapcode . '-' . $mab->mabseq; } diff --git a/classes/task/process_aws_sora_updates.php b/classes/task/process_aws_sora_updates.php index 5865d7b..30c0a63 100644 --- a/classes/task/process_aws_sora_updates.php +++ b/classes/task/process_aws_sora_updates.php @@ -17,6 +17,7 @@ namespace local_sitsgradepush\task; use local_sitsgradepush\extension\sora_queue_processor; +use local_sitsgradepush\extensionmanager; /** * Scheduled task to process AWS SORA updates. @@ -43,7 +44,7 @@ public function get_name() { */ public function execute(): void { // Skip if extension is not enabled. - if (!get_config('local_sitsgradepush', 'extension_enabled')) { + if (!extensionmanager::is_extension_enabled()) { mtrace('Extension processing is not enabled. Exiting...'); return; } diff --git a/classes/task/process_extensions_new_enrolment.php b/classes/task/process_extensions_new_enrolment.php new file mode 100644 index 0000000..d2f5e29 --- /dev/null +++ b/classes/task/process_extensions_new_enrolment.php @@ -0,0 +1,142 @@ +. + +namespace local_sitsgradepush\task; + +use core\task\adhoc_task; +use core\task\manager as coretaskmanager; +use local_sitsgradepush\extension\sora; +use local_sitsgradepush\extensionmanager; +use local_sitsgradepush\logger; +use local_sitsgradepush\manager; + +/** + * Ad-hoc task to process extensions for new student enrolment in course. + * + * @package local_sitsgradepush + * @copyright 2024 onwards University College London {@link https://www.ucl.ac.uk/} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @author Alex Yeung + */ +class process_extensions_new_enrolment extends adhoc_task { + + /** @var int Number of students to process per batch. */ + const BATCH_LIMIT = 100; + + /** @var int Maximum retry attempts for a record. */ + const MAX_ATTEMPTS = 2; + + /** + * Return name of the task. + * + * @return string + * @throws \coding_exception + */ + public function get_name() { + return get_string('task:process_extensions_new_enrolment', 'local_sitsgradepush'); + } + + /** + * Execute the task. + * + * @throws \dml_exception + */ + public function execute() { + global $DB; + + // Get task data. + $courseid = $this->get_custom_data()->courseid; + + // Get all user enrolment events for the course. + $userenrolments = extensionmanager::get_user_enrolment_events($courseid); + + // Fetch all mappings for the course with extension enabled. + $manager = manager::get_manager(); + $mappings = $manager->get_assessment_mappings_by_courseid($courseid, true); + + // Delete the user enrolment events for that course if no mappings found. + // Nothing we can do without mappings. + // When there is a new mapping, the extensions will be processed by process_extensions_new_mapping task. + if (empty($mappings)) { + $DB->delete_records('local_sitsgradepush_enrol', ['courseid' => $courseid]); + } + + // Process SORA extension for each mapping. + foreach ($mappings as $mapping) { + $studentsbycode = []; + // Get fresh students data from SITS for the mapping. + $students = $manager->get_students_from_sits($mapping, true); + + // Create a map of students by student code. + foreach ($students as $student) { + $studentsbycode[$student['code']] = $student; + } + + // Process SORA extension for each user enrolment event. + foreach ($userenrolments as $userenrolment) { + try { + // Get user's student ID number. + $studentidnumber = $DB->get_field('user', 'idnumber', ['id' => $userenrolment->userid]); + + // Check if the student's code exists in the pre-mapped list. + if (isset($studentsbycode[$studentidnumber])) { + // Process SORA extension. + $sora = new sora(); + $sora->set_properties_from_get_students_api($studentsbycode[$studentidnumber]); + $sora->process_extension([$mapping]); + + // Delete the student from the list to avoid duplicate processing. + unset($studentsbycode[$studentidnumber]); + } + // Delete the user enrolment event after processing. + $DB->delete_records('local_sitsgradepush_enrol', ['id' => $userenrolment->id]); + } catch (\Exception $e) { + $userenrolment->attempts++; + $DB->update_record('local_sitsgradepush_enrol', $userenrolment); + logger::log($e->getMessage(), null, "User ID: $userenrolment->userid, Mapping ID: $mapping->id"); + } + } + } + + // Re-queue another ad-hoc task if there are more entries for this course. + if (!empty(extensionmanager::get_user_enrolment_events($courseid))) { + $nexttask = new self(); + $nexttask->set_custom_data(['courseid' => $courseid]); + coretaskmanager::queue_adhoc_task($nexttask); + } + } + + /** + * Check if an ad-hoc task already exists for the course. + * + * @param int $courseid + * @return bool + * @throws \dml_exception + */ + public static function adhoc_task_exists(int $courseid): bool { + global $DB; + + $sql = "SELECT id + FROM {task_adhoc} + WHERE " . $DB->sql_compare_text('classname') . " = ? AND " . $DB->sql_compare_text('customdata') . " = ?"; + $params = [ + '\\local_sitsgradepush\\task\\process_extensions_new_enrolment', + json_encode(['courseid' => (string) $courseid]), + ]; + + return $DB->record_exists_sql($sql, $params); + } +} diff --git a/classes/task/process_extensions.php b/classes/task/process_extensions_new_mapping.php similarity index 75% rename from classes/task/process_extensions.php rename to classes/task/process_extensions_new_mapping.php index d8ded8d..a964724 100644 --- a/classes/task/process_extensions.php +++ b/classes/task/process_extensions_new_mapping.php @@ -19,6 +19,7 @@ use core\task\adhoc_task; use local_sitsgradepush\extensionmanager; use local_sitsgradepush\logger; +use local_sitsgradepush\manager; /** * Ad-hoc task to process extensions, i.e. SORA and EC. @@ -28,7 +29,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @author Alex Yeung */ -class process_extensions extends adhoc_task { +class process_extensions_new_mapping extends adhoc_task { /** * Return name of the task. @@ -37,7 +38,7 @@ class process_extensions extends adhoc_task { * @throws \coding_exception */ public function get_name() { - return get_string('task:processextensions', 'local_sitsgradepush'); + return get_string('task:process_extensions_new_mapping', 'local_sitsgradepush'); } /** @@ -53,8 +54,15 @@ public function execute() { throw new \moodle_exception('error:customdatamapidnotset', 'local_sitsgradepush'); } + // Check assessment mapping exists. + if (!$mapping = manager::get_manager()->get_mab_and_map_info_by_mapping_id($data->mapid)) { + throw new \moodle_exception('error:mab_or_mapping_not_found', 'local_sitsgradepush', '', $data->mapid); + } + + $students = manager::get_manager()->get_students_from_sits($mapping, true); + // Process SORA extension. - extensionmanager::update_sora_for_mapping($data->mapid); + extensionmanager::update_sora_for_mapping($mapping, $students); // Process EC extension (To be implemented). } catch (\Exception $e) { diff --git a/classes/taskmanager.php b/classes/taskmanager.php index 13e4b9a..3eba0df 100644 --- a/classes/taskmanager.php +++ b/classes/taskmanager.php @@ -20,7 +20,7 @@ use core\task\manager as coretaskmanager; use local_sitsgradepush\assessment\assessmentfactory; use local_sitsgradepush\extension\extension; -use local_sitsgradepush\task\process_extensions; +use local_sitsgradepush\task\process_extensions_new_mapping; /** * Manager class which handles push task. @@ -410,17 +410,17 @@ public static function send_email_notification(int $taskid): void { } /** - * Add an adhoc task to process extensions for a mapping. + * Add an adhoc task to process extensions for a new mapping. * * @param int $mappingid * @return void * @throws \dml_exception */ - public static function add_process_extensions_adhoc_task(int $mappingid): void { + public static function add_process_extensions_for_new_mapping_adhoc_task(int $mappingid): void { global $DB; try { - $mapping = $DB->get_record(manager::TABLE_ASSESSMENT_MAPPING, ['id' => $mappingid]); + $mapping = $DB->get_record(manager::TABLE_ASSESSMENT_MAPPING, ['id' => $mappingid, 'enableextension' => 1]); // Check if the assessment mapping exists. if (!$mapping) { @@ -429,7 +429,7 @@ public static function add_process_extensions_adhoc_task(int $mappingid): void { // Add an adhoc task to process extensions if the mapped assessment is supported. if (in_array($mapping->moduletype, extension::SUPPORTED_MODULE_TYPES) ) { - $task = new process_extensions(); + $task = new process_extensions_new_mapping(); $task->set_custom_data((object)['mapid' => $mappingid]); coretaskmanager::queue_adhoc_task($task); } @@ -437,4 +437,22 @@ public static function add_process_extensions_adhoc_task(int $mappingid): void { logger::log($e->getMessage()); } } + + /** + * Add an adhoc task to process extensions for a new student enrolment in course. + * + * @param int $courseid + * @return void + * @throws \dml_exception + */ + public static function add_process_extensions_for_enrolment_adhoc_task(int $courseid): void { + try { + // Add an adhoc task to process extensions if the mapped assessment is supported. + $task = new process_extensions_new_mapping(); + $task->set_custom_data((object)['courseid' => $courseid]); + coretaskmanager::queue_adhoc_task($task); + } catch (\moodle_exception $e) { + logger::log($e->getMessage()); + } + } } diff --git a/classes/user_enrolment_callbacks.php b/classes/user_enrolment_callbacks.php new file mode 100644 index 0000000..a1fdd16 --- /dev/null +++ b/classes/user_enrolment_callbacks.php @@ -0,0 +1,69 @@ +. + +namespace local_sitsgradepush; + +use core\task\manager as coretaskmanager; +use core_enrol\hook\after_user_enrolled; +use local_sitsgradepush\task\process_extensions_new_enrolment; + +/** + * Hook callbacks to get the enrolment information. + * + * @package local_sitsgradepush + * @copyright 2024 onwards University College London {@link https://www.ucl.ac.uk/} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @author Alex Yeung + */ +class user_enrolment_callbacks { + + /** + * Callback for the user_enrolment hook. + * + * @param after_user_enrolled $hook + * @throws \dml_exception|\coding_exception + */ + public static function process_extensions(after_user_enrolled $hook): void { + global $DB; + + // Exit if extension is not enabled. + if (!extensionmanager::is_extension_enabled()) { + return; + } + + $instance = $hook->get_enrolinstance(); + + // Check if user is enrolling a gradable role in the course. + if (!extensionmanager::user_is_enrolling_a_gradable_role($instance->roleid)) { + return; // User is not enrolling a gradable role, exit early. + } + + // Add user enrolment event to database. + $event = new \stdClass(); + $event->courseid = $instance->courseid; + $event->userid = $hook->get_userid(); + $event->timecreated = time(); + $DB->insert_record('local_sitsgradepush_enrol', $event); + + // Check if an ad-hoc task already exists for the course. + if (!process_extensions_new_enrolment::adhoc_task_exists($instance->courseid)) { + // Create a new ad-hoc task for the course. + $task = new task\process_extensions_new_enrolment(); + $task->set_custom_data(['courseid' => $instance->courseid]); + coretaskmanager::queue_adhoc_task($task); + } + } +} diff --git a/db/caches.php b/db/caches.php index d89fe0b..b0d8c75 100644 --- a/db/caches.php +++ b/db/caches.php @@ -41,4 +41,9 @@ 'simplekeys' => true, 'simpledata' => false, ], + 'mappingmabinfo' => [ + 'mode' => cache_store::MODE_APPLICATION, + 'simplekeys' => true, + 'simpledata' => false, + ], ]; diff --git a/db/hooks.php b/db/hooks.php new file mode 100644 index 0000000..d22e6f6 --- /dev/null +++ b/db/hooks.php @@ -0,0 +1,33 @@ +. + +/** + * Hook callbacks for enrol_manual + * + * @package local_sitsgradepush + * @copyright 2024 onwards University College London {@link https://www.ucl.ac.uk/} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @author Alex Yeung + */ + +defined('MOODLE_INTERNAL') || die(); + +$callbacks = [ + [ + 'hook' => core_enrol\hook\after_user_enrolled::class, + 'callback' => 'local_sitsgradepush\user_enrolment_callbacks::process_extensions', + ], +]; diff --git a/db/install.xml b/db/install.xml index 8108462..065bb11 100644 --- a/db/install.xml +++ b/db/install.xml @@ -1,5 +1,5 @@ - @@ -132,5 +132,20 @@ + + + + + + + + + + + + + + +
diff --git a/db/upgrade.php b/db/upgrade.php index 77e85ea..c5c8f4c 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -582,5 +582,32 @@ function xmldb_local_sitsgradepush_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2024110100, 'local', 'sitsgradepush'); } + if ($oldversion < 2024110102) { + + // Define table local_sitsgradepush_enrol to be created. + $table = new xmldb_table('local_sitsgradepush_enrol'); + + // Adding fields to table local_sitsgradepush_enrol. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('courseid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('attempts', XMLDB_TYPE_INTEGER, '3', null, null, null, '0'); + + // Adding keys to table local_sitsgradepush_enrol. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + + // Adding indexes to table local_sitsgradepush_enrol. + $table->add_index('idx_course_attempts', XMLDB_INDEX_NOTUNIQUE, ['courseid', 'attempts']); + + // Conditionally launch create table for local_sitsgradepush_enrol. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Sitsgradepush savepoint reached. + upgrade_plugin_savepoint(true, 2024110102, 'local', 'sitsgradepush'); + } + return true; } diff --git a/lang/en/local_sitsgradepush.php b/lang/en/local_sitsgradepush.php index 0e53ef1..3139f48 100644 --- a/lang/en/local_sitsgradepush.php +++ b/lang/en/local_sitsgradepush.php @@ -92,6 +92,7 @@ $string['error:cannotgetsoragroupid'] = 'Cannot SORA group ID'; $string['error:componentgrademapped'] = '{$a} had been mapped to another activity.'; $string['error:componentgradepushed'] = '{$a} cannot be removed because it has Marks Transfer records.'; +$string['error:course_data_not_set.'] = 'Course data not set.'; $string['error:coursemodulenotfound'] = 'Course module not found.'; $string['error:customdatamapidnotset'] = 'Mapping ID is not set in the task custom data.'; $string['error:duplicatedtask'] = 'There is already a transfer task in queue / processing for this assessment mapping.'; @@ -99,6 +100,7 @@ $string['error:ecextensionnotsupported'] = 'EC extension is not supported for this assessment.'; $string['error:empty_json_data'] = 'Empty JSON data'; $string['error:emptyresponse'] = 'Empty response received when calling {$a}.'; +$string['error:extension_not_enabled_for_mapping'] = 'Extension is not enabled for this mapping. Mapping ID: {$a}'; $string['error:extensiondataisnotset'] = 'Extension data is not set.'; $string['error:failtomapassessment'] = 'Failed to map assessment component to source.'; $string['error:grade_items_not_found'] = 'Grade items not found.'; @@ -115,6 +117,7 @@ $string['error:mab_has_push_records'] = 'Assessment component mapping cannot be updated as marks have been transferred for {$a}'; $string['error:mab_invalid_for_mapping'] = 'This assessment component is not valid for mapping due to the following reasons: {$a}.'; $string['error:mab_not_found'] = 'Assessment component not found. ID: {$a}'; +$string['error:mab_or_mapping_not_found'] = 'Mab or mapping not found. Mapping ID: {$a}'; $string['error:mapassessment'] = 'You do not have permission to map assessment.'; $string['error:marks_transfer_failed'] = 'Marks transfer failed.'; $string['error:missingparams'] = 'Missing parameters.'; @@ -139,6 +142,7 @@ $string['error:submission_log_transfer_failed'] = 'Submission Transfer failed.'; $string['error:tasknotfound'] = 'Transfer task not found.'; $string['error:turnitin_numparts'] = 'Turnitin assignment with multiple parts is not supported by Marks Transfer.'; +$string['error:user_data_not_set.'] = 'User data is not set.'; $string['event:assessment_mapped'] = 'Assessment mapped'; $string['form:alert_no_mab_found'] = 'No assessment components found'; $string['form:info_turnitin_numparts'] = 'Please note Turnitin assignment with multiple parts is not supported by Marks Transfer.'; @@ -244,7 +248,8 @@ $string['task:adhoctask'] = 'Adhoc Task'; $string['task:assesstype:name'] = 'Insert Assessment Type for Pre-mapped Assessments'; $string['task:process_aws_sora_updates'] = 'Process AWS SORA updates'; -$string['task:processextensions'] = 'Process SORA and EC extensions'; +$string['task:process_extensions_new_enrolment'] = 'Process SORA and EC extensions for new student enrolment'; +$string['task:process_extensions_new_mapping'] = 'Process SORA and EC extensions for new assessment mapping'; $string['task:pushtask:name'] = 'Schedule Transfer Task'; $string['task:requested:success'] = 'Transfer task requested successfully'; $string['task:status:completed'] = 'completed'; diff --git a/tests/extension/extension_test.php b/tests/extension/extension_test.php index 4033353..e893a3f 100644 --- a/tests/extension/extension_test.php +++ b/tests/extension/extension_test.php @@ -131,7 +131,7 @@ public function test_no_overrides_for_mapping_without_extension_enabled(): void // Process the extension. $ec = new ec(); $ec->set_properties_from_aws_message($message); - $ec->process_extension(); + $ec->process_extension($ec->get_mappings_by_mab($ec->get_mab_identifier())); $override = $DB->get_record('assign_overrides', ['assignid' => $this->assign1->id, 'userid' => $this->student1->id]); $this->assertEmpty($override); @@ -157,7 +157,7 @@ public function test_ec_process_extension_assign(): void { // Process the extension by passing the JSON event data. $ec = new ec(); $ec->set_properties_from_aws_message($message); - $ec->process_extension(); + $ec->process_extension($ec->get_mappings_by_mab($ec->get_mab_identifier())); // Calculate the new deadline. // Assume EC is using a new deadline without time. Extract the time part. @@ -191,7 +191,7 @@ public function test_ec_process_extension_quiz(): void { // Process the extension by passing the JSON event data. $ec = new ec(); $ec->set_properties_from_aws_message($message); - $ec->process_extension(); + $ec->process_extension($ec->get_mappings_by_mab($ec->get_mab_identifier())); // Calculate the new deadline. // Assume EC is using a new deadline without time. Extract the time part. @@ -228,7 +228,7 @@ public function test_sora_process_extension(): void { // Process the extension by passing the JSON event data. $sora = new sora(); $sora->set_properties_from_aws_message(tests_data_provider::get_sora_event_data()); - $sora->process_extension(); + $sora->process_extension($sora->get_mappings_by_userid($sora->get_userid())); // Test SORA override group exists. $groupid = $DB->get_field('groups', 'id', ['name' => $sora->get_extension_group_name()]); @@ -249,25 +249,44 @@ public function test_sora_process_extension(): void { } /** - * Test the update SORA extension for students in a mapping. + * Test the update SORA extension for students in a mapping with extension off. * * @covers \local_sitsgradepush\extensionmanager::update_sora_for_mapping * @return void - * @throws \dml_exception|\coding_exception|\ReflectionException + * @throws \dml_exception|\coding_exception */ - public function test_update_sora_for_mapping(): void { + public function test_update_sora_for_mapping_with_extension_off(): void { global $DB; - // Test error is logged when the MAB is not found. - $mapid = 0; - extensionmanager::update_sora_for_mapping($mapid); + // Set extension disabled. + set_config('extension_enabled', '0', 'local_sitsgradepush'); + + // The mapping inserted should be extension disabled. + $this->setup_for_sora_testing(); + + // Get mappings. + $mappings = manager::get_manager()->get_assessment_mappings_by_courseid($this->course1->id); + $mapping = reset($mappings); + // Process SORA extension for each mapping. + extensionmanager::update_sora_for_mapping($mapping, []); // Check error log. - $errormessage = get_string('error:mab_not_found', 'local_sitsgradepush', $mapid); + $errormessage = get_string('error:extension_not_enabled_for_mapping', 'local_sitsgradepush', $mapping->id); $sql = "SELECT * FROM {local_sitsgradepush_err_log} WHERE message = :message AND data = :data"; - $params = ['message' => $errormessage, 'data' => "Mapping ID: $mapid"]; + $params = ['message' => $errormessage, 'data' => "Mapping ID: $mapping->id"]; $log = $DB->get_record_sql($sql, $params); $this->assertNotEmpty($log); + } + + /** + * Test the update SORA extension for students in a mapping. + * + * @covers \local_sitsgradepush\extensionmanager::update_sora_for_mapping + * @return void + * @throws \dml_exception|\coding_exception|\ReflectionException|\moodle_exception + */ + public function test_update_sora_for_mapping(): void { + global $DB; // Set up the SORA extension. $this->setup_for_sora_testing(); @@ -279,9 +298,10 @@ public function test_update_sora_for_mapping(): void { tests_data_provider::set_protected_property($manager, 'apiclient', $apiclient); // Process all mappings for SORA. - $mappings = $DB->get_records('local_sitsgradepush_mapping'); + $mappings = $manager->get_assessment_mappings_by_courseid($this->course1->id); foreach ($mappings as $mapping) { - extensionmanager::update_sora_for_mapping($mapping->id); + $students = $manager->get_students_from_sits($mapping); + extensionmanager::update_sora_for_mapping($mapping, $students); } // Test SORA override group exists. @@ -302,6 +322,80 @@ public function test_update_sora_for_mapping(): void { $this->assertEquals($override->groupid, $groupid); } + /** + * Test the user is enrolling a gradable role. + * + * @covers \local_sitsgradepush\extensionmanager::user_is_enrolling_a_gradable_role + * @return void + */ + public function test_user_is_enrolling_a_gradable_role(): void { + global $CFG; + + // Test when role is gradable. + $CFG->gradebookroles = '1,2,3'; + $roleid = 2; + $result = extensionmanager::user_is_enrolling_a_gradable_role($roleid); + $this->assertTrue($result); + + // Test when role is not gradable. + $roleid = 4; + $result = extensionmanager::user_is_enrolling_a_gradable_role($roleid); + $this->assertFalse($result); + + // Test when gradebookroles is null. + $CFG->gradebookroles = null; + $roleid = 1; + $result = extensionmanager::user_is_enrolling_a_gradable_role($roleid); + $this->assertFalse($result); + } + + /** + * Test get user enrolment events. + * + * @covers \local_sitsgradepush\extensionmanager::get_user_enrolment_events + * @return void + * @throws \coding_exception + * @throws \dml_exception + */ + public function test_get_user_enrolment_events(): void { + global $DB; + + // Create user enrolment events. + $events = []; + for ($i = 0; $i < 3; $i++) { + $event = new \stdClass(); + $event->courseid = 1; + $event->userid = $i + 1; + $event->attempts = $i; + $event->timecreated = time(); + $events[] = $event; + } + $DB->insert_records('local_sitsgradepush_enrol', $events); + + // Get user enrolment events. + $result = extensionmanager::get_user_enrolment_events(1); + $this->assertCount(2, $result); + } + + /** + * Test is_extension_enabled method. + * + * @covers \local_sitsgradepush\extensionmanager::is_extension_enabled + * @return void + * @throws \dml_exception + */ + public function test_is_extension_enabled(): void { + // Test when extension is enabled in config. + set_config('extension_enabled', '1', 'local_sitsgradepush'); + $result = extensionmanager::is_extension_enabled(); + $this->assertTrue($result); + + // Test when extension is disabled in config. + set_config('extension_enabled', '0', 'local_sitsgradepush'); + $result = extensionmanager::is_extension_enabled(); + $this->assertFalse($result); + } + /** * Set up the environment for EC testing. * @@ -375,7 +469,7 @@ private function insert_mapping(int $mabid, int $courseid, \stdClass $assessment 'moduletype' => $modtype, 'componentgradeid' => $mabid, 'reassessment' => 0, - 'enableextension' => get_config('local_sitsgradepush', 'extension_enabled') ? 1 : 0, + 'enableextension' => extensionmanager::is_extension_enabled() ? 1 : 0, 'timecreated' => time(), 'timemodified' => time(), ]); diff --git a/tests/manager_test.php b/tests/manager_test.php index 1fbe5ac..748b8f3 100644 --- a/tests/manager_test.php +++ b/tests/manager_test.php @@ -1270,20 +1270,20 @@ public function test_check_response(): void { /** * Test the get mab by mapping id method. * - * @covers \local_sitsgradepush\manager::get_mab_by_mapping_id + * @covers \local_sitsgradepush\manager::get_mab_and_map_info_by_mapping_id * @return void * @throws \ReflectionException * @throws \coding_exception * @throws \dml_exception * @throws \moodle_exception */ - public function test_get_mab_by_mapping_id(): void { + public function test_get_mab_and_map_info_by_mapping_id(): void { // Set up the test environment. $this->setup_testing_environment(assessmentfactory::get_assessment('mod', $this->assign1->cmid)); // Test the mab is returned. - $mab = $this->manager->get_mab_by_mapping_id($this->mappingid1); - $this->assertEquals($this->mab1->id, $mab->id); + $mab = $this->manager->get_mab_and_map_info_by_mapping_id($this->mappingid1); + $this->assertEquals($this->mab1->id, $mab->mabid); } /** diff --git a/version.php b/version.php index 8068fdd..46e5569 100644 --- a/version.php +++ b/version.php @@ -27,7 +27,7 @@ $plugin->component = 'local_sitsgradepush'; $plugin->release = '0.1.0'; -$plugin->version = 2024110100; +$plugin->version = 2024110102; $plugin->requires = 2024042200; $plugin->maturity = MATURITY_ALPHA; $plugin->dependencies = [