diff --git a/.github/workflows/moodle-ci.yml b/.github/workflows/moodle-ci.yml index 34b94e7..b66da86 100644 --- a/.github/workflows/moodle-ci.yml +++ b/.github/workflows/moodle-ci.yml @@ -48,9 +48,6 @@ jobs: - php: '8.1' moodle-branch: 'MOODLE_404_STABLE' database: 'mariadb' - - php: '8.1' - moodle-branch: 'MOODLE_403_STABLE' - database: 'mariadb' steps: - name: Check out repository code diff --git a/classes/assessment/activity.php b/classes/assessment/activity.php index 53c40fa..3f2fab0 100644 --- a/classes/assessment/activity.php +++ b/classes/assessment/activity.php @@ -16,6 +16,7 @@ namespace local_sitsgradepush\assessment; +use core\context\module; use grade_item; use local_sitsgradepush\manager; @@ -32,8 +33,8 @@ abstract class activity extends assessment { /** @var \stdClass Course module object */ public \stdClass $coursemodule; - /** @var \stdClass Context object */ - public \context_module $context; + /** @var module Context object */ + public module $context; /** * Constructor. @@ -107,6 +108,15 @@ public function get_module_name(): string { return $this->coursemodule->modname; } + /** + * Get the module context. + * + * @return module + */ + public function get_module_context(): module { + return $this->context; + } + /** * Get the course module id. * diff --git a/classes/assessment/assessment.php b/classes/assessment/assessment.php index 2a0ae6c..62cb975 100644 --- a/classes/assessment/assessment.php +++ b/classes/assessment/assessment.php @@ -16,6 +16,9 @@ namespace local_sitsgradepush\assessment; +use local_sitsgradepush\extension\ec; +use local_sitsgradepush\extension\extension; +use local_sitsgradepush\extension\sora; use local_sitsgradepush\manager; /** @@ -55,6 +58,36 @@ public function __construct(string $sourcetype, int $sourceid) { $this->set_instance(); } + /** + * Apply extension to the assessment. + * + * @param extension $extension + * @return void + * @throws \moodle_exception + */ + public function apply_extension(extension $extension): void { + $check = $this->is_valid_for_extension(); + if (!$check->valid) { + throw new \moodle_exception($check->errorcode, 'local_sitsgradepush'); + } + + // Do extension base on the extension type. + if ($extension instanceof ec) { + $this->apply_ec_extension($extension); + } else if ($extension instanceof sora) { + // Skip SORA overrides if the assessment is not an exam. + if (!$this->is_exam()) { + return; + } + // Skip SORA overrides if the end date of the assessment is in the past. + if ($this->get_end_date() < time()) { + return; + } + + $this->apply_sora_extension($extension); + } + } + /** * Get the source id. * @@ -166,6 +199,24 @@ public function get_end_date(): ?int { return null; } + /** + * Get module name. Return empty string if not applicable. + * + * @return string + */ + public function get_module_name(): string { + return ''; + } + + /** + * Check if the assessment is an exam. Override in child class if needed. + * + * @return bool + */ + public function is_exam(): bool { + return false; + } + /** * Check if the assessment is valid for marks transfer. * @@ -199,6 +250,31 @@ public function check_assessment_validity(): \stdClass { return $result; } + /** + * Check if the assessment is valid for EC or SORA extension. + * + * @return \stdClass + */ + public function is_valid_for_extension(): \stdClass { + if ($this->get_start_date() === null || $this->get_end_date() === null) { + return $this->set_validity_result(false, 'error:assessmentdatesnotset'); + } + + return $this->set_validity_result(true); + } + + /** + * Delete SORA override for a Moodle assessment. + * + * @param array $groupids Default SORA overrides group ids in the course. + * @return void + * @throws \moodle_exception + */ + public function delete_sora_overrides(array $groupids): void { + // Default not supported. Override in child class if needed. + throw new \moodle_exception('error:soraextensionnotsupported', 'local_sitsgradepush'); + } + /** * Set validity result. * @@ -227,11 +303,34 @@ protected function get_equivalent_grade_from_mark(float $marks): ?string { return $equivalentgrade; } + /** + * Apply EC extension. + * + * @param ec $ec + * @return void + * @throws \moodle_exception + */ + protected function apply_ec_extension(ec $ec): void { + // Default not supported. Override in child class if needed. + throw new \moodle_exception('error:ecextensionnotsupported', 'local_sitsgradepush'); + } + + /** + * Apply SORA extension. + * + * @param sora $sora + * @return void + * @throws \moodle_exception + */ + protected function apply_sora_extension(sora $sora): void { + // Default not supported. Override in child class if needed. + throw new \moodle_exception('error:soraextensionnotsupported', 'local_sitsgradepush'); + } + /** * Get all participants for the assessment. * * @return array */ abstract public function get_all_participants(): array; - } diff --git a/classes/assessment/assign.php b/classes/assessment/assign.php index 222417e..779a43b 100644 --- a/classes/assessment/assign.php +++ b/classes/assessment/assign.php @@ -16,6 +16,10 @@ namespace local_sitsgradepush\assessment; +use cache; +use local_sitsgradepush\extension\ec; +use local_sitsgradepush\extension\sora; + /** * Class for assignment assessment. * @@ -26,13 +30,23 @@ */ class assign extends activity { + /** + * Is the user a participant in the assignment. + * + * @param int $userid + * @return bool + */ + public function is_user_a_participant(int $userid): bool { + return is_enrolled($this->get_module_context(), $userid, 'mod/assign:submit'); + } + /** * Get all participants. * * @return array */ public function get_all_participants(): array { - return get_enrolled_users($this->context, 'mod/assign:submit'); + return get_enrolled_users($this->get_module_context(), 'mod/assign:submit'); } /** @@ -52,4 +66,219 @@ public function get_start_date(): ?int { public function get_end_date(): ?int { return $this->sourceinstance->duedate; } + + /** + * Check if this assignment is an exam. + * + * @return bool + */ + public function is_exam(): bool { + $start = $this->get_start_date(); + $end = $this->get_end_date(); + + if ($start && $end) { + $duration = $end - $start; + return $duration > 0 && $duration <= HOURSECS * 5; + } + + return false; + } + + /** + * Delete SORA override for the assignment. + * + * @param array $groupids Default SORA overrides group ids in the course. + * @return void + * @throws \coding_exception + * @throws \dml_exception + */ + public function delete_sora_overrides(array $groupids): void { + global $CFG, $DB; + require_once($CFG->dirroot . '/mod/assign/locallib.php'); + + // Skip if group ids are empty. + if (empty($groupids)) { + return; + } + + // Find all group overrides for the assignment having the default SORA overrides group ids. + [$insql, $params] = $DB->get_in_or_equal($groupids, SQL_PARAMS_NAMED); + $params['assignid'] = $this->sourceinstance->id; + $sql = "SELECT id FROM {assign_overrides} WHERE assignid = :assignid AND groupid $insql AND userid IS NULL"; + + $overrides = $DB->get_records_sql($sql, $params); + + if (empty($overrides)) { + return; + } + + $assign = new \assign($this->context, $this->get_course_module(), null); + foreach ($overrides as $override) { + $assign->delete_override($override->id); + } + } + + /** + * Apply EC extension to the assessment. + * + * @param ec $ec The EC extension. + * @return void + */ + protected function apply_ec_extension(ec $ec): void { + global $CFG; + require_once($CFG->dirroot . '/mod/assign/locallib.php'); + $originalduedate = $this->get_end_date(); + + // EC is using a new deadline without time. Extract the time part of the original due date. + $time = date('H:i:s', $originalduedate); + + // Get the new date and time. + $newduedate = strtotime($ec->get_new_deadline() . ' ' . $time); + + // Override the assignment settings for user. + $this->overrides_due_date($newduedate, $ec->get_userid()); + } + + /** + * Apply SORA extension to the assessment. + * + * @param sora $sora The SORA extension. + * @return void + * @throws \moodle_exception + */ + protected function apply_sora_extension(sora $sora): void { + global $CFG; + require_once($CFG->dirroot . '/group/lib.php'); + + // Get time extension in seconds. + $timeextensionperhour = $sora->get_time_extension(); + + // Calculate the new due date. + // Find the difference between the start and end date in hours. Multiply by the time extension per hour. + $actualextension = (($this->get_end_date() - $this->get_start_date()) / HOURSECS) * $timeextensionperhour; + $newduedate = $this->get_end_date() + round($actualextension); + + // Get the group id, create if it doesn't exist and add the user to the group. + $groupid = $sora->get_sora_group_id($this->get_course_id(), $sora->get_userid()); + + if (!$groupid) { + throw new \moodle_exception('error:cannotgetsoragroupid', 'local_sitsgradepush'); + } + + $this->overrides_due_date($newduedate, $sora->get_userid(), $groupid); + } + + /** + * Overrides the due date for the user or group. + * + * @param int $newduedate The new due date. + * @param int $userid The user id. + * @param int|null $groupid The group id. + * @return void + */ + private function overrides_due_date(int $newduedate, int $userid, ?int $groupid = null): void { + global $CFG, $DB; + require_once($CFG->dirroot . '/mod/assign/locallib.php'); + require_once($CFG->dirroot . '/mod/assign/lib.php'); + + // It is a group override. + if ($groupid) { + $sql = 'SELECT * FROM {assign_overrides} WHERE assignid = :assignid AND groupid = :groupid AND userid IS NULL'; + $params = [ + 'assignid' => $this->get_source_instance()->id, + 'groupid' => $groupid, + ]; + } else { + // It is a user override. + $sql = 'SELECT * FROM {assign_overrides} WHERE assignid = :assignid AND userid = :userid AND groupid IS NULL'; + $params = [ + 'assignid' => $this->get_source_instance()->id, + 'userid' => $userid, + ]; + } + + // Check if the override already exists. + $override = $DB->get_record_sql($sql, $params); + if ($override) { + // No need to update if the due date is the same. + if ($override->duedate == $newduedate) { + return; + } + $override->duedate = $newduedate; + $DB->update_record('assign_overrides', $override); + $newrecord = false; + } else { + // Create a new override. + $override = new \stdClass(); + $override->assignid = $this->get_source_instance()->id; + $override->duedate = $newduedate; + $override->userid = $groupid ? null : $userid; + $override->groupid = $groupid ?: null; + $override->sortorder = $groupid ? 0 : null; + $override->id = $DB->insert_record('assign_overrides', $override); + + // Reorder the group overrides. + if ($groupid) { + reorder_group_overrides($override->assignid); + } + $newrecord = true; + } + + // Clear the cache. + $this->clear_override_cache($override); + + // Trigger the event. + $this->trigger_override_event($override, $newrecord); + + // Update the assign events. + assign_update_events(new \assign($this->context, $this->get_course_module(), null), $override); + } + + /** + * Trigger the override event. + * + * @param \stdClass $override The override object. + * @param bool $newrecord Whether the override is a new record. + * @return void + * @throws \coding_exception + */ + private function trigger_override_event(\stdClass $override, bool $newrecord): void { + $params = [ + 'context' => $this->context, + 'other' => [ + 'assignid' => $override->assignid, + ], + ]; + + $params['objectid'] = $override->id; + if (!$override->groupid) { + $params['relateduserid'] = $override->userid; + if ($newrecord) { + $event = \mod_assign\event\user_override_created::create($params); + } else { + $event = \mod_assign\event\user_override_updated::create($params); + } + } else { + $params['other']['groupid'] = $override->groupid; + if ($newrecord) { + $event = \mod_assign\event\group_override_created::create($params); + } else { + $event = \mod_assign\event\group_override_updated::create($params); + } + } + $event->trigger(); + } + + /** + * Clear the override cache. + * + * @param \stdClass $override The override object. + * @return void + * @throws \coding_exception + */ + private function clear_override_cache(\stdClass $override): void { + $cachekey = $override->groupid ? + "{$override->assignid}_g_{$override->groupid}" : "{$override->assignid}_u_{$override->userid}"; + cache::make('mod_assign', 'overrides')->delete($cachekey); + } } diff --git a/classes/assessment/quiz.php b/classes/assessment/quiz.php index 4b26d7d..1fc94ff 100644 --- a/classes/assessment/quiz.php +++ b/classes/assessment/quiz.php @@ -16,6 +16,10 @@ namespace local_sitsgradepush\assessment; +use local_sitsgradepush\extension\ec; +use local_sitsgradepush\extension\sora; +use mod_quiz\local\override_manager; + /** * Class for assessment quiz. * @@ -25,13 +29,24 @@ * @author Alex Yeung */ class quiz extends activity { + + /** + * Is the user a participant in the quiz. + * + * @param int $userid + * @return bool + */ + public function is_user_a_participant(int $userid): bool { + return is_enrolled($this->get_module_context(), $userid, 'mod/quiz:attempt'); + } + /** * Get all participants. * * @return array */ public function get_all_participants(): array { - return get_enrolled_users($this->context, 'mod/quiz:attempt'); + return get_enrolled_users($this->get_module_context(), 'mod/quiz:attempt'); } /** @@ -51,4 +66,127 @@ public function get_start_date(): ?int { public function get_end_date(): ?int { return $this->get_source_instance()->timeclose; } + + /** + * Check if this quiz is an exam. + * + * @return bool + */ + public function is_exam(): bool { + $originaltimelimit = $this->get_source_instance()->timelimit; + return $originaltimelimit > 0 && $originaltimelimit <= HOURMINS * 5; + } + + /** + * Delete SORA override for the quiz. + * + * @param array $groupids Default SORA overrides group ids in the course. + * @return void + * @throws \coding_exception + * @throws \dml_exception + */ + public function delete_sora_overrides(array $groupids): void { + global $CFG, $DB; + require_once($CFG->dirroot . '/mod/assign/locallib.php'); + + // Skip if group ids are empty. + if (empty($groupids)) { + return; + } + + // Find all group overrides for the quiz having the default SORA overrides group ids. + [$insql, $params] = $DB->get_in_or_equal($groupids, SQL_PARAMS_NAMED); + $params['quizid'] = $this->sourceinstance->id; + $sql = "SELECT * FROM {quiz_overrides} WHERE quiz = :quizid AND groupid $insql AND userid IS NULL"; + + $overrides = $DB->get_records_sql($sql, $params); + + if (empty($overrides)) { + return; + } + + // Delete the overrides. + $this->get_override_manager()->delete_overrides($overrides); + } + + /** + * Apply EC extension to the quiz. + * + * @param ec $ec EC extension object. + * @return void + */ + protected function apply_ec_extension(ec $ec): void { + // EC is using a new deadline without time. Extract the time part from the original deadline. + $time = date('H:i:s', $this->get_end_date()); + + // Get the new date and time. + $newduedate = strtotime($ec->get_new_deadline() . ' ' . $time); + + // Save the override. + $this->get_override_manager()->save_override(['userid' => $ec->get_userid(), 'timeclose' => $newduedate]); + } + + /** + * Apply SORA extension to the quiz. + * + * @param sora $sora SORA extension object. + * @return void + * @throws \moodle_exception + */ + protected function apply_sora_extension(sora $sora): void { + global $DB; + + // Get extra time from SORA. + $timeextension = $sora->get_time_extension(); + + // Calculate the new time limit. + $originaltimelimit = $this->get_source_instance()->timelimit; + $newtimelimit = $originaltimelimit + (($originaltimelimit / HOURSECS) * $timeextension); + + // Get the group id. + $groupid = $sora->get_sora_group_id($this->get_course_id(), $sora->get_userid()); + + if (!$groupid) { + throw new \moodle_exception('error:cannotgetsoragroupid', 'local_sitsgradepush'); + } + + $overridedata = [ + 'quiz' => $this->get_source_instance()->id, + 'groupid' => $groupid, + 'timelimit' => round($newtimelimit), + ]; + + // Get the override record if it exists. + $override = $DB->get_record( + 'quiz_overrides', + [ + 'quiz' => $this->get_source_instance()->id, + 'groupid' => $groupid, + 'userid' => null, + ] + ); + + if ($override) { + $overridedata['id'] = $override->id; + } + + // Save the override. + $this->get_override_manager()->save_override($overridedata); + } + + /** + * Get the quiz override manager. + * + * @return override_manager + * @throws \coding_exception + */ + private function get_override_manager(): override_manager { + $quiz = $this->get_source_instance(); + $quiz->cmid = $this->get_coursemodule_id(); + + return new override_manager( + $quiz, + $this->get_module_context() + ); + } } diff --git a/classes/aws/sqs.php b/classes/aws/sqs.php new file mode 100644 index 0000000..3e8fff6 --- /dev/null +++ b/classes/aws/sqs.php @@ -0,0 +1,90 @@ +. + +namespace local_sitsgradepush\aws; + +use Aws\Sqs\SqsClient; +use core\aws\client_factory; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/lib/aws-sdk/src/functions.php'); +require_once($CFG->dirroot . '/lib/guzzlehttp/guzzle/src/functions.php'); + +/** + * Class for SQS client. + * + * @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 sqs { + + /** @var SqsClient AWS client */ + protected SqsClient $client; + + /** + * Constructor. + * + * @throws \moodle_exception + */ + public function __construct() { + // Check required configs are set. + $configs = $this->check_required_configs_are_set(); + + $this->client = client_factory::get_client('\Aws\Sqs\SqsClient', [ + 'region' => $configs->aws_region, + 'version' => 'latest', + 'credentials' => [ + 'key' => $configs->aws_key, + 'secret' => $configs->aws_secret, + ], + ]); + } + + /** + * Get the client. + * + * @return SqsClient + */ + public function get_client(): SqsClient { + return $this->client; + } + + /** + * Check required configs are set. + * + * @return object + * @throws \moodle_exception + */ + private function check_required_configs_are_set(): \stdClass { + $requiredfields = ['aws_region', 'aws_key', 'aws_secret']; + + $configs = []; + foreach ($requiredfields as $field) { + // Get the config value. + $config = get_config('local_sitsgradepush', $field); + + // Check if the config is empty. + if (empty($config)) { + throw new \moodle_exception('error:missingrequiredconfigs', 'local_sitsgradepush'); + } + $configs[$field] = $config; + } + return (object) $configs; + } +} 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/event/assessment_mapped.php b/classes/event/assessment_mapped.php index 8f3916f..bb394e8 100644 --- a/classes/event/assessment_mapped.php +++ b/classes/event/assessment_mapped.php @@ -43,7 +43,7 @@ protected function init(): void { * @throws \coding_exception */ public static function get_name(): string { - return get_string('eventname', 'local_sitsgradepush'); + return get_string('event:assessment_mapped', 'local_sitsgradepush'); } /** @@ -52,6 +52,6 @@ public static function get_name(): string { * @return string */ public function get_description(): string { - return "An assessment is mapped to a SITS assessment component."; + return get_string('event:assessment_mapped_desc', 'local_sitsgradepush'); } } diff --git a/classes/extension/aws_queue_processor.php b/classes/extension/aws_queue_processor.php new file mode 100644 index 0000000..cfc02db --- /dev/null +++ b/classes/extension/aws_queue_processor.php @@ -0,0 +1,239 @@ +. + +namespace local_sitsgradepush\extension; + +use local_sitsgradepush\aws\sqs; +use local_sitsgradepush\logger; + +/** + * Parent class for queue processors. + * + * @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 + */ +abstract class aws_queue_processor { + + /** @var int Maximum number of messages to fetch per call. 10 is the highest number, limited by AWS */ + const MAX_MESSAGES = 10; + + /** @var int Visibility timeout in seconds */ + const VISIBILITY_TIMEOUT = 60; + + /** @var int Wait time in seconds */ + const WAIT_TIME_SECONDS = 5; + + /** @var string Message status - processed */ + const STATUS_PROCESSED = 'processed'; + + /** @var string Message status - failed */ + const STATUS_FAILED = 'failed'; + + /** @var int Maximum number of batches */ + const MAX_BATCHES = 30; + + /** @var int Maximum number of messages to fetch */ + const MAX_MESSAGES_TO_PROCESS = 300; + + /** @var int Maximum execution time in seconds */ + const MAX_EXECUTION_TIME = 1800; // 30 minutes + + /** + * Get the queue URL. + * + * @return string + */ + abstract protected function get_queue_url(): string; + + /** + * Process the message. + * + * @param array $messagebody AWS SQS message body + * @return void + */ + abstract protected function process_message(array $messagebody): void; + + /** + * Fetch messages from the queue. + * + * @param int $maxmessages Maximum number of messages to fetch + * @param int $visibilitytimeout Visibility timeout in seconds + * @param int $waittimeseconds Wait time in seconds + * @return array + */ + protected function fetch_messages( + int $maxmessages = self::MAX_MESSAGES, + int $visibilitytimeout = self::VISIBILITY_TIMEOUT, + int $waittimeseconds = self::WAIT_TIME_SECONDS + ): array { + $sqs = new sqs(); + $result = $sqs->get_client()->receiveMessage([ + 'QueueUrl' => $this->get_queue_url(), + 'MaxNumberOfMessages' => $maxmessages, + 'VisibilityTimeout' => $visibilitytimeout, + 'WaitTimeSeconds' => $waittimeseconds, + ]); + + return $result->get('Messages') ?? []; + } + + /** + * Check if message is already processed. + * + * @param string $messageid AWS SQS Message ID + * @return bool True if message is processed already, false otherwise + * @throws \dml_exception + */ + protected function is_processed_message(string $messageid): bool { + global $DB; + + try { + // Allow processing if message has not been processed successfully. + return $DB->record_exists( + 'local_sitsgradepush_aws_log', + ['messageid' => $messageid, 'status' => self::STATUS_PROCESSED] + ); + } catch (\Exception $e) { + logger::log($e->getMessage(), null, 'Failed to check message status'); + return false; + } + } + + /** + * Execute the queue processor with batch processing support + * + * @return void + * @throws \Exception + */ + public function execute(): void { + try { + $processedcount = 0; + $batchnumber = 0; + $starttime = time(); + + do { + // Check safety limits. + if ($batchnumber >= self::MAX_BATCHES) { + mtrace("Maximum batch limit (" . self::MAX_BATCHES . ") reached"); + break; + } + + if ($processedcount >= self::MAX_MESSAGES_TO_PROCESS) { + mtrace("Maximum message limit (" . self::MAX_MESSAGES_TO_PROCESS . ") reached"); + break; + } + + $elapsedtime = time() - $starttime; + if ($elapsedtime >= self::MAX_EXECUTION_TIME) { + mtrace("Maximum execution time (" . self::MAX_EXECUTION_TIME . " seconds) reached"); + break; + } + + // Fetch messages from the queue. + $messages = $this->fetch_messages(); + if (empty($messages)) { + if ($batchnumber === 0) { + mtrace('No messages found.'); + } + break; + } + + $batchnumber++; + mtrace(sprintf('Processing batch %d with %d messages...', $batchnumber, count($messages))); + + foreach ($messages as $message) { + try { + if ($this->is_processed_message($message['MessageId'])) { + mtrace("Skipping processed message: {$message['MessageId']}"); + continue; + } + $data = json_decode($message['Body'], true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \Exception('Invalid JSON data: ' . json_last_error_msg()); + } + $this->process_message($data); + $this->save_message_record($message); + $this->delete_message($message['ReceiptHandle']); + $processedcount++; + } catch (\Exception $e) { + logger::log($e->getMessage(), null, static::class . ' Processing Error'); + $this->save_message_record($message, self::STATUS_FAILED, $e->getMessage()); + } + } + + } while (!empty($messages)); + + mtrace(sprintf('Completed processing %d messages in %d batches (%.2f seconds)', + $processedcount, + $batchnumber, + time() - $starttime + )); + } catch (\Exception $e) { + logger::log($e->getMessage(), null, static::class . ' Queue Error'); + throw $e; + } + } + + /** + * Delete the message from the queue. + * + * @param string $receipthandle + * @return void + */ + protected function delete_message(string $receipthandle): void { + $sqs = new sqs(); + $sqs->get_client()->deleteMessage([ + 'QueueUrl' => $this->get_queue_url(), + 'ReceiptHandle' => $receipthandle, + ]); + } + + /** + * Save message processing details to database + * + * @param array $message SQS message data + * @param string $status Processing status + * @param string|null $error Error message if any + * @return bool|int Returns record ID on success, false on failure + * @throws \dml_exception + */ + protected function save_message_record( + array $message, + string $status = self::STATUS_PROCESSED, + ?string $error = null + ): bool|int { + global $DB, $USER; + + try { + $record = new \stdClass(); + $record->messageid = $message['MessageId']; + $record->receipthandle = $message['ReceiptHandle']; + $record->queueurl = $this->get_queue_url(); + $record->status = $status; + $record->payload = $message['Body']; + $record->error_message = $error; + $record->timecreated = time(); + $record->usermodified = $USER->id; + + return $DB->insert_record('local_sitsgradepush_aws_log', $record); + } catch (\Exception $e) { + logger::log($e->getMessage(), null, 'Failed to save message record'); + return false; + } + } +} diff --git a/classes/extension/ec.php b/classes/extension/ec.php new file mode 100644 index 0000000..12151a2 --- /dev/null +++ b/classes/extension/ec.php @@ -0,0 +1,102 @@ +. + +namespace local_sitsgradepush\extension; + +use local_sitsgradepush\assessment\assessmentfactory; +use local_sitsgradepush\logger; + +/** + * Class for extenuating circumstance (EC). + * + * @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 ec extends extension { + + /** @var string New deadline */ + private string $newdeadline; + + /** @var string MAB identifier, e.g. CCME0158A6UF-001 */ + protected string $mabidentifier; + + /** + * Returns the new deadline. + * + * @return string + */ + public function get_new_deadline(): string { + return $this->newdeadline; + } + + /** + * Process the extension. + * + * @param array $mappings + * @throws \dml_exception + */ + public function process_extension(array $mappings): void { + // Exit if empty mappings. + if (empty($mappings)) { + return; + } + + foreach ($mappings as $mapping) { + try { + $assessment = assessmentfactory::get_assessment($mapping->sourcetype, $mapping->sourceid); + if ($assessment->is_user_a_participant($this->userid)) { + $assessment->apply_extension($this); + } + } catch (\Exception $e) { + logger::log($e->getMessage(), null, "Mapping ID: $mapping->id"); + } + } + } + + /** + * Set the EC properties from the AWS EC update message. + * Note: The AWS EC update message is not yet developed, will implement this when the message is available. + * + * @param string $messagebody + * @return void + * @throws \dml_exception|\moodle_exception + */ + public function set_properties_from_aws_message(string $messagebody): void { + // Decode the JSON message. + $messagedata = $this->parse_event_json($messagebody); + + // Set the user ID of the student. + $this->set_userid($messagedata->student_code); + + // Set the MAB identifier. + $this->mabidentifier = $messagedata->identifier; + + // Set new deadline. + $this->newdeadline = $messagedata->new_deadline; + } + + /** + * Set the EC properties from the get students API. + * + * @param array $student + * @return void + */ + public function set_properties_from_get_students_api(array $student): void { + // Will implement this when the get students API includes EC data. + } +} diff --git a/classes/extension/extension.php b/classes/extension/extension.php new file mode 100644 index 0000000..8959641 --- /dev/null +++ b/classes/extension/extension.php @@ -0,0 +1,196 @@ +. + +namespace local_sitsgradepush\extension; + +use local_sitsgradepush\manager; + +/** + * Parent class for extension. For example, EC and SORA. + * + * @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 + */ +abstract class extension implements iextension { + + /** @var array Supported module types */ + const SUPPORTED_MODULE_TYPES = ['assign', 'quiz']; + + /** @var int User ID */ + protected int $userid; + + /** @var bool Used to check if the extension data is set. */ + protected bool $dataisset = false; + + /** + * Set properties from JSON message like SORA / EC update message from AWS. + * + * @param string $messagebody + * @return void + */ + abstract public function set_properties_from_aws_message(string $messagebody): void; + + /** + * Set properties from get students API. + * + * @param array $student + * @return void + */ + abstract public function set_properties_from_get_students_api(array $student): void; + + /** + * Get the user ID. + * + * @return int + */ + public function get_userid(): int { + return $this->userid; + } + + /** + * Get the MAB identifier. + * + * @return string + */ + public function get_mab_identifier(): string { + return $this->mabidentifier; + } + + /** + * Check if the module type is supported. + * + * @param string|null $module + * @return bool + */ + public static function is_module_supported(?string $module): bool { + if (empty($module)) { + return false; + } + return in_array($module, self::SUPPORTED_MODULE_TYPES); + } + + /** + * Get all the assessment mappings by MAB identifier. + * + * @param string $mabidentifier + * @return array + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + */ + public function get_mappings_by_mab(string $mabidentifier): array { + global $DB; + + // Extract the map code and MAB sequence number from the MAB identifier. + $mapcode = explode('-', $mabidentifier)[0]; + $mabseq = explode('-', $mabidentifier)[1]; + + $params = [ + 'mapcode' => $mapcode, + 'mabseq' => $mabseq, + ]; + + // Currently only support assign and quiz. + [$insql, $inparams] = $DB->get_in_or_equal(self::SUPPORTED_MODULE_TYPES, SQL_PARAMS_NAMED); + $params = array_merge($params, $inparams); + + $sql = "SELECT am.* + FROM {". manager::TABLE_ASSESSMENT_MAPPING ."} am + JOIN {". manager::TABLE_COMPONENT_GRADE ."} mab ON am.componentgradeid = mab.id + WHERE mab.mapcode = :mapcode AND mab.mabseq = :mabseq AND am.moduletype $insql + AND am.enableextension = 1"; + + return $DB->get_records_sql($sql, $params); + } + + /** + * Get all the assessment mappings by user ID. + * + * @param int $userid + * @return array + * @throws \dml_exception|\coding_exception + */ + public function get_mappings_by_userid(int $userid): array { + global $DB; + + // Find all enrolled courses for the student. + $courses = enrol_get_users_courses($userid, true); + + // Get courses that are in the current academic year. + $courses = array_filter($courses, function($course) { + return manager::get_manager()->is_current_academic_year_activity($course->id); + }); + + // Extract the course IDs. + $courseids = array_map(function($course) { + return $course->id; + }, $courses); + + // Student is not enrolled in any courses that are in the current academic year. + if (empty($courseids)) { + return []; + } + + [$courseinsql, $courseinparam] = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED); + + // Currently only support assign and quiz. + [$modinsql, $modinparams] = $DB->get_in_or_equal(self::SUPPORTED_MODULE_TYPES, SQL_PARAMS_NAMED); + $params = array_merge($courseinparam, $modinparams); + + // Find all mapped moodle assessments for the student. + $sql = "SELECT am.* + FROM {". manager::TABLE_ASSESSMENT_MAPPING ."} am + JOIN {". manager::TABLE_COMPONENT_GRADE ."} mab ON am.componentgradeid = mab.id + WHERE am.courseid $courseinsql AND am.moduletype $modinsql AND am.enableextension = 1"; + + return $DB->get_records_sql($sql, $params); + } + + /** + * Parse the message JSON. + * + * @param string $message + * @return \stdClass + * @throws \Exception + */ + protected function parse_event_json(string $message): \stdClass { + $messageobject = json_decode($message); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \Exception(get_string('error:invalid_json_data', 'local_sitsgradepush', json_last_error_msg())); + } + if (empty($messageobject)) { + throw new \Exception(get_string('error:empty_json_data', 'local_sitsgradepush')); + } + return $messageobject; + } + + /** + * Set the user ID of the student. + * + * @param string $studentcode + * @return void + * @throws \dml_exception + */ + protected function set_userid(string $studentcode): void { + global $DB; + + // Find and set the user ID of the student. + $user = $DB->get_record('user', ['idnumber' => $studentcode], 'id', MUST_EXIST); + $this->userid = $user->id; + } +} diff --git a/classes/extension/iextension.php b/classes/extension/iextension.php new file mode 100644 index 0000000..c3f6c55 --- /dev/null +++ b/classes/extension/iextension.php @@ -0,0 +1,34 @@ +. + +namespace local_sitsgradepush\extension; + +/** + * Interface for extension. + * + * @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 + */ +interface iextension { + /** + * Process the extension. + * + * @param array $mappings SITS component mappings. + */ + public function process_extension(array $mappings): void; +} diff --git a/classes/extension/sora.php b/classes/extension/sora.php new file mode 100644 index 0000000..8c73370 --- /dev/null +++ b/classes/extension/sora.php @@ -0,0 +1,259 @@ +. + +namespace local_sitsgradepush\extension; + +use local_sitsgradepush\assessment\assessmentfactory; +use local_sitsgradepush\logger; + +/** + * Class for Summary of Reasonable Adjustments (SORA). + * + * @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 sora extends extension { + + /** @var string Prefix used to create SORA groups */ + const SORA_GROUP_PREFIX = 'DEFAULT-SORA-'; + + /** @var int Extra duration in minutes per hour */ + protected int $extraduration; + + /** @var int Rest duration in minutes per hour */ + protected int $restduration; + + /** @var int Time extension in seconds, including extra and rest duration */ + protected int $timeextension; + + /** + * Return the whole time extension in seconds, including extra and rest duration. + * + * @return int + */ + public function get_time_extension(): int { + return $this->timeextension; + } + + /** + * Return the extra duration in minutes. + * + * @return int + */ + public function get_extra_duration(): int { + return $this->extraduration; + } + + /** + * Return the rest duration in minutes. + * + * @return int + */ + public function get_rest_duration(): int { + return $this->restduration; + } + + /** + * Get the SORA group ID. Create the group if it does not exist. + * Add the user to the group. Remove the user from other SORA groups. + * + * @param int $courseid + * @param int $userid + * + * @return int + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + */ + public function get_sora_group_id(int $courseid, int $userid): int { + global $CFG; + require_once($CFG->dirroot . '/group/lib.php'); + + // Check group exists. + $groupid = groups_get_group_by_name($courseid, $this->get_extension_group_name()); + + if (!$groupid) { + // Create group. + $newgroup = new \stdClass(); + $newgroup->courseid = $courseid; + $newgroup->name = $this->get_extension_group_name(); + $newgroup->description = ''; + $newgroup->enrolmentkey = ''; + $newgroup->picture = 0; + $newgroup->visibility = GROUPS_VISIBILITY_OWN; + $newgroup->hidepicture = 0; + $newgroup->timecreated = time(); + $newgroup->timemodified = time(); + $groupid = groups_create_group($newgroup); + } + + // Add user to group. + if (!groups_add_member($groupid, $userid)) { + throw new \moodle_exception('error:cannotaddusertogroup', 'local_sitsgradepush'); + } + + // Remove user from previous SORA groups. + $this->remove_user_from_previous_sora_groups($groupid, $courseid, $userid); + + return $groupid; + } + + /** + * Get the SORA group name. + * + * @return string + */ + public function get_extension_group_name(): string { + return sprintf(self::SORA_GROUP_PREFIX . '%d', $this->get_extra_duration() + $this->get_rest_duration()); + } + + /** + * Set properties from AWS SORA update message. + * + * @param string $messagebody + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function set_properties_from_aws_message(string $messagebody): void { + + // Decode the JSON message body. + $messagedata = $this->parse_event_json($messagebody); + + // Check the message is valid. + if (empty($messagedata->entity->person_sora->sora[0])) { + throw new \moodle_exception('error:invalid_message', 'local_sitsgradepush', '', null, $messagebody); + } + + $soradata = $messagedata->entity->person_sora->sora[0]; + + // Set properties. + $this->extraduration = (int) $soradata->extra_duration ?? 0; + $this->restduration = (int) $soradata->rest_duration ?? 0; + + // A SORA update message must have at least one of the durations. + if ($this->extraduration == 0 && $this->restduration == 0) { + throw new \moodle_exception('error:invalid_duration', 'local_sitsgradepush'); + } + + // Calculate and set the time extension in seconds. + $this->timeextension = $this->calculate_time_extension($this->extraduration, $this->restduration); + + // Set the user ID of the student. + $this->set_userid($soradata->person->student_code); + + $this->dataisset = true; + } + + /** + * Set properties from get students API. + * + * @param array $student + * @return void + */ + public function set_properties_from_get_students_api(array $student): void { + // Set the user ID. + $this->set_userid($student['code']); + + // Set properties. + $this->extraduration = (int) $student['assessment']['sora_assessment_duration']; + $this->restduration = (int) $student['assessment']['sora_rest_duration']; + + // Calculate and set the time extension in seconds. + $this->timeextension = $this->calculate_time_extension($this->get_extra_duration(), $this->get_rest_duration()); + $this->dataisset = true; + } + + /** + * Process the extension. + * + * @param array $mappings + * + * @return void + * @throws \coding_exception + * @throws \dml_exception|\moodle_exception + */ + 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'); + } + + // Exit if SORA extra assessment duration and rest duration are both 0. + if ($this->extraduration == 0 && $this->restduration == 0) { + return; + } + + // Apply the extension to the assessments. + foreach ($mappings as $mapping) { + try { + $assessment = assessmentfactory::get_assessment($mapping->sourcetype, $mapping->sourceid); + if ($assessment->is_user_a_participant($this->userid)) { + $assessment->apply_extension($this); + } + } catch (\Exception $e) { + logger::log($e->getMessage()); + } + } + } + + /** + * Remove the user from previous SORA groups. + * + * @param int $newgroupid The new group ID or the group to keep the user in. + * @param int $courseid The course ID. + * @param int $userid The user ID. + * @return void + * @throws \dml_exception + */ + protected function remove_user_from_previous_sora_groups(int $newgroupid, int $courseid, int $userid): void { + global $DB; + + // Find all default SORA groups created by marks transfer. + $sql = 'SELECT g.id + FROM {groups} g + WHERE g.courseid = :courseid + AND g.name LIKE :name'; + $params = [ + 'courseid' => $courseid, + 'name' => self::SORA_GROUP_PREFIX . '%', + ]; + $soragroups = $DB->get_records_sql($sql, $params); + + foreach ($soragroups as $soragroup) { + if ($soragroup->id != $newgroupid) { + groups_remove_member($soragroup->id, $userid); + } + } + } + + /** + * Calculate the time extension in seconds. + * + * @param int $extraduration Extra duration in minutes. + * @param int $restduration Rest duration in minutes. + * @return int + */ + private function calculate_time_extension(int $extraduration, int $restduration): int { + return ($extraduration + $restduration) * MINSECS; + } +} diff --git a/classes/extension/sora_queue_processor.php b/classes/extension/sora_queue_processor.php new file mode 100644 index 0000000..d725b31 --- /dev/null +++ b/classes/extension/sora_queue_processor.php @@ -0,0 +1,56 @@ +. + +namespace local_sitsgradepush\extension; + +use local_sitsgradepush\manager; + +/** + * SORA queue processor. + * + * @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 sora_queue_processor extends aws_queue_processor { + /** + * Get the queue URL. + * + * @return string + * @throws \dml_exception + */ + protected function get_queue_url(): string { + return get_config('local_sitsgradepush', 'aws_sora_sqs_queue_url'); + } + + /** + * Process the aws SORA message. + * + * @param array $messagebody SORA message data body + * + * @throws \coding_exception + * @throws \moodle_exception + * @throws \dml_exception + */ + protected function process_message(array $messagebody): void { + $sora = new sora(); + $sora->set_properties_from_aws_message($messagebody['Message']); + // 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 new file mode 100644 index 0000000..7ff3bfb --- /dev/null +++ b/classes/extensionmanager.php @@ -0,0 +1,153 @@ +. + +namespace local_sitsgradepush; + +use local_sitsgradepush\assessment\assessment; +use local_sitsgradepush\assessment\assessmentfactory; +use local_sitsgradepush\extension\extension; +use local_sitsgradepush\extension\sora; +use local_sitsgradepush\task\process_extensions_new_enrolment; + +/** + * Manager class for extension related operations. + * + * @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 extensionmanager { + + /** + * Update SORA extension for students in a mapping. + * + * @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(\stdClass $mapping, array $students): void { + try { + if ($mapping->enableextension !== '1') { + throw new \moodle_exception('error:extension_not_enabled_for_mapping', 'local_sitsgradepush', '', $mapping->id); + } + + // If no students returned from SITS, nothing to do. + if (empty($students)) { + return; + } + + // 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([$mapping]); + } + } catch (\Exception $e) { + 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 + ); + } + + /** + * Delete SORA overrides for a Moodle assessment. + * + * @param \stdClass $deletedmapping + * @return void + * @throws \dml_exception + */ + public static function delete_sora_overrides(\stdClass $deletedmapping): void { + try { + // Get Moodle assessment. + $assessment = assessmentfactory::get_assessment($deletedmapping->sourcetype, $deletedmapping->sourceid); + + // Nothing to do if the module type is not supported. + if (!extension::is_module_supported($assessment->get_module_name())) { + return; + } + + $assessment->delete_sora_overrides(self::get_default_sora_groups_ids_in_course($deletedmapping->courseid)); + } catch (\Exception $e) { + logger::log($e->getMessage(), null, "Deleted Mapping: " . json_encode($deletedmapping)); + } + } + + /** + * Get the default SORA groups IDs in a course. + * + * @param int $courseid + * @return array + * @throws \dml_exception + */ + public static function get_default_sora_groups_ids_in_course(int $courseid): array { + global $DB; + $like = $DB->sql_like('name', ':name', false); + $defaultsoragroups = $DB->get_records_select( + 'groups', + "courseid = :courseid AND $like", + ['courseid' => $courseid, 'name' => sora::SORA_GROUP_PREFIX . '%'], + fields: 'id', + ); + return !empty($defaultsoragroups) ? array_keys($defaultsoragroups) : []; + } +} diff --git a/classes/manager.php b/classes/manager.php index 1625c26..e83918b 100644 --- a/classes/manager.php +++ b/classes/manager.php @@ -17,15 +17,14 @@ 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; use local_sitsgradepush\assessment\assessment; use local_sitsgradepush\assessment\assessmentfactory; +use local_sitsgradepush\extension\extension; use local_sitsgradepush\output\pushrecord; use local_sitsgradepush\submission\submissionfactory; @@ -180,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); @@ -214,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) { @@ -475,7 +474,10 @@ public function save_assessment_mapping(\stdClass $data): int|bool { // Checked in the above validation, the current mapping to this component grade // can be deleted as it does not have push records nor mapped to the current activity. $DB->delete_records(self::TABLE_ASSESSMENT_MAPPING, ['id' => $existingmapping->id]); - assesstype::update_assess_type($existingmapping, 'unlock'); + + // Delete any SORA overrides for the deleted mapping. + extensionmanager::delete_sora_overrides($existingmapping); + assesstype::update_assess_type($existingmapping, assesstype::ACTION_UNLOCK); } // Insert new mapping. @@ -488,11 +490,15 @@ public function save_assessment_mapping(\stdClass $data): int|bool { } $record->componentgradeid = $data->componentgradeid; $record->reassessment = $data->reassessment; + $record->enableextension = (extensionmanager::is_extension_enabled() && + (isset($record->moduletype) && extension::is_module_supported($record->moduletype))) ? 1 : 0; $record->timecreated = time(); $record->timemodified = time(); $newmappingid = $DB->insert_record(self::TABLE_ASSESSMENT_MAPPING, $record); - assesstype::update_assess_type($newmappingid, 'lock'); + + // Update assessment type of the mapped assessment for the assessment type plugin if it is installed. + assesstype::update_assess_type($newmappingid, assesstype::ACTION_LOCK); return $newmappingid; } @@ -604,12 +610,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( @@ -625,13 +632,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. @@ -649,7 +659,7 @@ public function get_students_from_sits(\stdClass $componentgrade): mixed { cachemanager::CACHE_AREA_STUDENTSPR, $key, $result, - strtotime('+30 days'), + DAYSECS * 30 ); } @@ -880,16 +890,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; } /** @@ -1439,6 +1480,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. * @@ -1457,7 +1523,7 @@ public function remove_mapping(int $courseid, int $mappingid): void { } // Check the mapping exists. - if (!$DB->record_exists(self::TABLE_ASSESSMENT_MAPPING, ['id' => $mappingid])) { + if (!$mapping = $DB->get_record(self::TABLE_ASSESSMENT_MAPPING, ['id' => $mappingid])) { throw new \moodle_exception('error:assessmentmapping', 'local_sitsgradepush', '', $mappingid); } @@ -1468,6 +1534,9 @@ public function remove_mapping(int $courseid, int $mappingid): void { // Everything is fine, remove the mapping. $DB->delete_records(self::TABLE_ASSESSMENT_MAPPING, ['id' => $mappingid]); + + // Delete any SORA overrides for the deleted mapping. + extensionmanager::delete_sora_overrides($mapping); } /** diff --git a/classes/observer.php b/classes/observer.php index 4c6f6b9..46a6758 100644 --- a/classes/observer.php +++ b/classes/observer.php @@ -15,7 +15,9 @@ // along with Moodle. If not, see . use local_sitsgradepush\cachemanager; +use local_sitsgradepush\extensionmanager; use local_sitsgradepush\manager; +use local_sitsgradepush\taskmanager; /** * Class for local_sitsgradepush observer. @@ -83,13 +85,20 @@ public static function assessment_mapped(\local_sitsgradepush\event\assessment_m $manager = manager::get_manager(); $mab = $manager->get_local_component_grade_by_id($data['other']['mabid']); + if (empty($mab)) { + return; + } + // Purge students cache for the mapped assessment component. // This is to get the latest student data for the same SITS assessment component. // For example, the re-assessment with the same SITS assessment component will have the latest student data // instead of using the cached data, such as the resit_number. - if (!empty($mab)) { - $key = implode('_', [cachemanager::CACHE_AREA_STUDENTSPR, $mab->mapcode, $mab->mabseq]); - cachemanager::purge_cache(cachemanager::CACHE_AREA_STUDENTSPR, $key); + $key = implode('_', [cachemanager::CACHE_AREA_STUDENTSPR, $mab->mapcode, $mab->mabseq]); + cachemanager::purge_cache(cachemanager::CACHE_AREA_STUDENTSPR, $key); + + // Add the process extensions adhoc task if process extensions is enabled. + 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 new file mode 100644 index 0000000..30c0a63 --- /dev/null +++ b/classes/task/process_aws_sora_updates.php @@ -0,0 +1,55 @@ +. + +namespace local_sitsgradepush\task; + +use local_sitsgradepush\extension\sora_queue_processor; +use local_sitsgradepush\extensionmanager; + +/** + * Scheduled task to process AWS SORA updates. + * + * @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_aws_sora_updates extends \core\task\scheduled_task { + /** + * Return name of the task. + * + * @return string + * @throws \coding_exception + */ + public function get_name() { + return get_string('task:process_aws_sora_updates', 'local_sitsgradepush'); + } + + /** + * Execute the task. + * @throws \Exception + */ + public function execute(): void { + // Skip if extension is not enabled. + if (!extensionmanager::is_extension_enabled()) { + mtrace('Extension processing is not enabled. Exiting...'); + return; + } + + $processor = new sora_queue_processor(); + $processor->execute(); + } +} 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_new_mapping.php b/classes/task/process_extensions_new_mapping.php new file mode 100644 index 0000000..a964724 --- /dev/null +++ b/classes/task/process_extensions_new_mapping.php @@ -0,0 +1,73 @@ +. + +namespace local_sitsgradepush\task; + +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. + * + * @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_mapping extends adhoc_task { + + /** + * Return name of the task. + * + * @return string + * @throws \coding_exception + */ + public function get_name() { + return get_string('task:process_extensions_new_mapping', 'local_sitsgradepush'); + } + + /** + * Execute the task. + */ + public function execute() { + try { + // Get task data. + $data = $this->get_custom_data(); + + // Check the assessment component id is set. + if (!isset($data->mapid)) { + 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($mapping, $students); + + // Process EC extension (To be implemented). + } catch (\Exception $e) { + $mapid = $data->mapid ? 'Map ID: ' . $data->mapid : ''; + logger::log($e->getMessage(), null, $mapid); + } + } +} diff --git a/classes/taskmanager.php b/classes/taskmanager.php index 9c58bdd..3eba0df 100644 --- a/classes/taskmanager.php +++ b/classes/taskmanager.php @@ -17,7 +17,10 @@ namespace local_sitsgradepush; use context_user; +use core\task\manager as coretaskmanager; use local_sitsgradepush\assessment\assessmentfactory; +use local_sitsgradepush\extension\extension; +use local_sitsgradepush\task\process_extensions_new_mapping; /** * Manager class which handles push task. @@ -405,4 +408,51 @@ public static function send_email_notification(int $taskid): void { throw new \moodle_exception('error:tasknotfound', 'local_sitsgradepush'); } } + + /** + * 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_for_new_mapping_adhoc_task(int $mappingid): void { + global $DB; + + try { + $mapping = $DB->get_record(manager::TABLE_ASSESSMENT_MAPPING, ['id' => $mappingid, 'enableextension' => 1]); + + // Check if the assessment mapping exists. + if (!$mapping) { + throw new \moodle_exception('error:assessmentmapping', 'local_sitsgradepush', '', $mappingid); + } + + // 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_new_mapping(); + $task->set_custom_data((object)['mapid' => $mappingid]); + coretaskmanager::queue_adhoc_task($task); + } + } catch (\moodle_exception $e) { + 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 a3f288c..065bb11 100644 --- a/db/install.xml +++ b/db/install.xml @@ -1,5 +1,5 @@ - @@ -14,6 +14,7 @@ + @@ -110,5 +111,41 @@ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + +
diff --git a/db/tasks.php b/db/tasks.php index 83aaa7b..6f85f1f 100644 --- a/db/tasks.php +++ b/db/tasks.php @@ -44,4 +44,13 @@ 'month' => '*', 'dayofweek' => '*', ], + [ + 'classname' => 'local_sitsgradepush\task\process_aws_sora_updates', + 'blocking' => 0, + 'minute' => '0', + 'hour' => '0', + 'day' => '*', + 'month' => '*', + 'dayofweek' => '*', + ], ]; diff --git a/db/upgrade.php b/db/upgrade.php index bf50611..c5c8f4c 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -534,5 +534,80 @@ function xmldb_local_sitsgradepush_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2024101000, 'local', 'sitsgradepush'); } + if ($oldversion < 2024101100) { + + // Define field enableextension to be added to local_sitsgradepush_mapping. + $table = new xmldb_table('local_sitsgradepush_mapping'); + $field = new xmldb_field('enableextension', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0', 'reassessmentseq'); + + // Conditionally launch add field enableextension. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Sitsgradepush savepoint reached. + upgrade_plugin_savepoint(true, 2024101100, 'local', 'sitsgradepush'); + } + + if ($oldversion < 2024110100) { + + // Define table local_sitsgradepush_aws_log to be created. + $table = new xmldb_table('local_sitsgradepush_aws_log'); + + // Adding fields to table local_sitsgradepush_aws_log. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('messageid', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null); + $table->add_field('receipthandle', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null); + $table->add_field('queueurl', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null); + $table->add_field('status', XMLDB_TYPE_CHAR, '20', null, XMLDB_NOTNULL, null, null); + $table->add_field('payload', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null); + $table->add_field('error_message', XMLDB_TYPE_TEXT, null, null, null, null, null); + $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('usermodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + + // Adding keys to table local_sitsgradepush_aws_log. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + + // Adding indexes to table local_sitsgradepush_aws_log. + $table->add_index('messageid', XMLDB_INDEX_NOTUNIQUE, ['messageid']); + $table->add_index('status', XMLDB_INDEX_NOTUNIQUE, ['status']); + $table->add_index('timecreated', XMLDB_INDEX_NOTUNIQUE, ['timecreated']); + + // Conditionally launch create table for local_sitsgradepush_aws_log. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Sitsgradepush savepoint reached. + 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 bea483b..321a255 100644 --- a/lang/en/local_sitsgradepush.php +++ b/lang/en/local_sitsgradepush.php @@ -80,19 +80,28 @@ $string['email:transfer_history_text'] = 'View marks transfer details for this activity.'; $string['email:username'] = 'Dear {$a},'; $string['error:assessmentclassnotfound'] = 'Assessment class not found. Classname: {$a}'; +$string['error:assessmentdatesnotset'] = 'Assessment start date or end date date is not set.'; $string['error:assessmentisnotmapped'] = 'This activity is not mapped to any assessment component.'; $string['error:assessmentmapping'] = 'Assessment mapping is not found. ID: {$a}'; $string['error:assessmentnotfound'] = 'Error getting assessment. ID: {$a}'; $string['error:ast_code_exam_room_code_not_matched'] = 'Centrally managed exam NOT due to take place in Moodle'; $string['error:ast_code_not_supported'] = 'Assessment Type {$a} is not expected to take place in Moodle'; $string['error:cannot_change_source'] = 'Cannot change source as marks have already been transferred for this assessment component.'; +$string['error:cannotaddusertogroup'] = 'Cannot add user to the SORA group'; $string['error:cannotdisplaygradesforgradebookwhileregrading'] = 'Cannot display grades for gradebook item or category while grades are being recalculated.'; +$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.'; $string['error:duplicatemapping'] = 'Cannot map multiple assessment components with same module delivery to an activity. Mapcode: {$a}'; +$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.'; $string['error:gradebook_disabled'] = 'Gradebook transfer feature is disabled.'; @@ -100,15 +109,19 @@ $string['error:gradesneedregrading'] = 'Marks transfer is not available while grades are being recalculated.'; $string['error:gradetype_not_supported'] = 'The grade type of this assessment is not supported for marks transfer.'; $string['error:inserttask'] = 'Failed to insert task.'; +$string['error:invalid_json_data'] = 'Invalid JSON data: {$a}'; +$string['error:invalid_message'] = 'Invalid message received.'; $string['error:invalid_source_type'] = 'Invalid source type. {$a}'; $string['error:lesson_practice'] = 'Practice lessons have no grades'; $string['error:lti_no_grades'] = 'LTI activity is set to not send grades to gradebook'; $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.'; +$string['error:missingrequiredconfigs'] = 'Missing required configs.'; $string['error:mks_scheme_not_supported'] = 'Marking Scheme is not supported for marks transfer'; $string['error:multiplemappingsnotsupported'] = 'Multiple assessment component mappings is not supported by {$a}'; $string['error:no_mab_found'] = 'No assessment component found for this module delivery.'; @@ -124,11 +137,14 @@ $string['error:remove_mapping'] = 'You do not have permission to remove mapping.'; $string['error:resit_number_zero_for_reassessment'] = 'Student resit number should not be zero for a reassessment.'; $string['error:same_map_code_for_same_activity'] = 'An activity cannot be mapped to more than one assessment component with same map code'; +$string['error:soraextensionnotsupported'] = 'SORA extension is not supported for this assessment.'; $string['error:studentnotfound'] = 'Student with idnumber {$a->idnumber} not found for component grade {$a->componentgrade}'; $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['event:assessment_mapped_desc'] = 'An assessment is mapped to a SITS assessment component.'; $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.'; $string['gradepushassessmentselect'] = 'Select SITS assessment'; @@ -189,10 +205,22 @@ $string['settings:apiclient'] = 'API client'; $string['settings:apiclient:desc'] = 'Choose which API client to use'; $string['settings:apiclientselect'] = 'Select API client'; +$string['settings:awskey'] = 'AWS Key'; +$string['settings:awskey:desc'] = 'AWS access key id'; +$string['settings:awsregion'] = 'AWS Region'; +$string['settings:awsregion:desc'] = 'AWS Server Region'; +$string['settings:awssecret'] = 'AWS Secret'; +$string['settings:awssecret:desc'] = 'AWS secret access key'; +$string['settings:awssettings'] = 'AWS'; +$string['settings:awssettings:desc'] = 'Settings for AWS'; +$string['settings:awssoraqueueurl'] = 'AWS SORA Queue URL'; +$string['settings:awssoraqueueurl:desc'] = 'URL for receiving SORA SQS messages'; $string['settings:concurrenttasks'] = 'Number of concurrent tasks allowed'; $string['settings:concurrenttasks:desc'] = 'Number of concurrent ad-hoc tasks allowed'; $string['settings:enable'] = 'Enable Marks Transfer'; $string['settings:enable:desc'] = 'Enable Marks Transfer to SITS'; +$string['settings:enableextension'] = 'Enable assessment extension'; +$string['settings:enableextension:desc'] = 'Allow extension (EC / SORA) to be applied to assessments automatically'; $string['settings:enablesublogpush'] = 'Enable Submission Log transfer'; $string['settings:enablesublogpush:desc'] = 'Enable submission log transfer to SITS'; $string['settings:generalsettingsheader'] = 'General Settings'; @@ -220,6 +248,9 @@ $string['subplugintype_sitsapiclient_plural'] = 'API clients used for data integration.'; $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: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/settings.php b/settings.php index d38f572..c993d0f 100644 --- a/settings.php +++ b/settings.php @@ -149,6 +149,48 @@ get_string('settings:reassessment_enabled:desc', 'local_sitsgradepush'), '0' )); + + // Setting to enable/disable submission log push. + $settings->add(new admin_setting_configcheckbox( + 'local_sitsgradepush/extension_enabled', + get_string('settings:enableextension', 'local_sitsgradepush'), + get_string('settings:enableextension:desc', 'local_sitsgradepush'), + '0' + )); + + // Setting for AWS. + $settings->add(new admin_setting_heading('local_sitsgradepush_aws_settings', + get_string('settings:awssettings', 'local_sitsgradepush'), + get_string('settings:awssettings:desc', 'local_sitsgradepush') + )); + + // AWS region. + $settings->add(new admin_setting_configtext('local_sitsgradepush/aws_region', + get_string('settings:awsregion', 'local_sitsgradepush'), + get_string('settings:awsregion:desc', 'local_sitsgradepush'), + 'eu-west-2' + )); + + // AWS key. + $settings->add(new admin_setting_configtext('local_sitsgradepush/aws_key', + get_string('settings:awskey', 'local_sitsgradepush'), + get_string('settings:awskey:desc', 'local_sitsgradepush'), + 'AKIAX3UE2A7B2VLXHL2O' + )); + + // AWS secret. + $settings->add(new admin_setting_configpasswordunmask('local_sitsgradepush/aws_secret', + get_string('settings:awssecret', 'local_sitsgradepush'), + get_string('settings:awssecret:desc', 'local_sitsgradepush'), + 'CHANGEME' + )); + + // AWS SORA queue URL. + $settings->add(new admin_setting_configtext('local_sitsgradepush/aws_sora_sqs_queue_url', + get_string('settings:awssoraqueueurl', 'local_sitsgradepush'), + get_string('settings:awssoraqueueurl:desc', 'local_sitsgradepush'), + 'https://sqs.eu-west-2.amazonaws.com/540370667459/person-sora-dev' + )); } $subplugins = core_plugin_manager::instance()->get_plugins_of_type('sitsapiclient'); diff --git a/tests/aws/sqs_test.php b/tests/aws/sqs_test.php new file mode 100644 index 0000000..e80822d --- /dev/null +++ b/tests/aws/sqs_test.php @@ -0,0 +1,96 @@ +. + +namespace local_sitsgradepush; + +use Aws\Sqs\SqsClient; +use local_sitsgradepush\aws\sqs; + +/** + * Tests for the sqs class. + * + * @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 + */ +final class sqs_test extends \advanced_testcase { + + /** + * Set up the test. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + $this->resetAfterTest(); + } + + /** + * Test get_client returns sqs client. + * + * @covers \local_sitsgradepush\aws\sqs::get_client + * @return void + */ + public function test_get_client_returns_sqs_client(): void { + $sqs = new sqs(); + $client = $sqs->get_client(); + $this->assertInstanceOf(SqsClient::class, $client); + } + + /** + * Test constructor throws exception if configs missing. + * + * @covers \local_sitsgradepush\aws\sqs::__construct + * @return void + * @throws \coding_exception + */ + public function test_constructor_throws_exception_if_configs_missing(): void { + // Set the required configs to empty. + set_config('aws_region', '', 'local_sitsgradepush'); + set_config('aws_key', '', 'local_sitsgradepush'); + set_config('aws_secret', '', 'local_sitsgradepush'); + + $this->expectException(\moodle_exception::class); + $this->expectExceptionMessage(get_string('error:missingrequiredconfigs', 'local_sitsgradepush')); + + new sqs(); + } + + /** + * Test check_required_configs_are_set returns configs. + * + * @covers \local_sitsgradepush\aws\sqs::__construct + * @covers \local_sitsgradepush\aws\sqs::check_required_configs_are_set + * @return void + * @throws \ReflectionException + */ + public function test_check_required_configs_are_set_returns_configs(): void { + // Set the required configs. + set_config('aws_region', 'us-east', 'local_sitsgradepush'); + set_config('aws_key', 'awskey-1234', 'local_sitsgradepush'); + set_config('aws_secret', 'secret-2468', 'local_sitsgradepush'); + + $sqs = new sqs(); + $reflection = new \ReflectionClass($sqs); + $method = $reflection->getMethod('check_required_configs_are_set'); + $method->setAccessible(true); + $configs = $method->invoke($sqs); + $this->assertEquals('us-east', $configs->aws_region); + $this->assertEquals('awskey-1234', $configs->aws_key); + $this->assertEquals('secret-2468', $configs->aws_secret); + } +} diff --git a/tests/base_test_class.php b/tests/base_test_class.php new file mode 100644 index 0000000..68a4869 --- /dev/null +++ b/tests/base_test_class.php @@ -0,0 +1,62 @@ +. + +namespace local_sitsgradepush; + +use local_sitsgradepush\api\iclient; +use local_sitsgradepush\api\client; +use local_sitsgradepush\api\irequest; +use moodle_exception; + +/** + * Base test class to provide common methods for testing. + * + * @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 + */ +abstract class base_test_class extends \advanced_testcase { + /** + * Get api client for testing. + * + * @param bool $shouldthrowexception + * @param mixed|null $response + * + * @return iclient + * @throws \coding_exception + * @throws \dml_exception + */ + protected function get_apiclient_for_testing(bool $shouldthrowexception, mixed $response = null): iclient { + $apiclient = $this->createMock(client::class); + $apiclient->expects($this->any()) + ->method('get_client_name') + ->willReturn(get_string('pluginname', 'sitsapiclient_' . get_config('local_sitsgradepush', 'apiclient'))); + $apiclient->expects($this->any()) + ->method('build_request') + ->willReturn($this->createMock(irequest::class)); + if ($shouldthrowexception) { + $apiclient->expects($this->any()) + ->method('send_request') + ->will($this->throwException(new moodle_exception('error:webclient', 'sitsapiclient_easikit'))); + } else { + $apiclient->expects($this->any()) + ->method('send_request') + ->willReturn($response); + } + return $apiclient; + } +} diff --git a/tests/extension/extension_common.php b/tests/extension/extension_common.php new file mode 100644 index 0000000..12821a4 --- /dev/null +++ b/tests/extension/extension_common.php @@ -0,0 +1,147 @@ +. + +namespace local_sitsgradepush; + +defined('MOODLE_INTERNAL') || die(); +global $CFG; +require_once($CFG->dirroot . '/local/sitsgradepush/tests/fixtures/tests_data_provider.php'); +require_once($CFG->dirroot . '/local/sitsgradepush/tests/base_test_class.php'); + +/** + * Base class for extension tests. + * + * @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 extension_common extends base_test_class { + + /** @var \stdClass $course1 Default test course 1 */ + protected \stdClass $course1; + + /** @var \stdClass Default test student 1 */ + protected \stdClass $student1; + + /** @var \stdClass Default test assignment 1 */ + protected \stdClass $assign1; + + /** @var \stdClass Default test quiz 1*/ + protected \stdClass $quiz1; + + /** + * Set up the test. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + $this->resetAfterTest(); + + // Set admin user. + $this->setAdminUser(); + + // Get data generator. + $dg = $this->getDataGenerator(); + + // Set Easikit API client. + set_config('apiclient', 'easikit', 'local_sitsgradepush'); + + // Setup testing environment. + set_config('late_summer_assessment_end_' . date('Y'), date('Y-m-d', strtotime('+2 month')), 'block_lifecycle'); + + // Enable the extension. + set_config('extension_enabled', '1', 'local_sitsgradepush'); + + // Create a custom category and custom field. + $dg->create_custom_field_category(['name' => 'CLC']); + $dg->create_custom_field(['category' => 'CLC', 'shortname' => 'course_year']); + + // Create test courses. + $this->course1 = $dg->create_course( + ['shortname' => 'C1', 'customfields' => [ + ['shortname' => 'course_year', 'value' => date('Y')], + ]]); + $this->student1 = $dg->create_user(['idnumber' => '12345678']); + $dg->enrol_user($this->student1->id, $this->course1->id); + + $assessmentstartdate = strtotime('+1 hour'); + $assessmentenddate = strtotime('+3 hours'); + + // Create test assignment 1. + $this->assign1 = $dg->create_module('assign', + [ + 'name' => 'Test Assignment 1', + 'course' => $this->course1->id, + 'allowsubmissionsfromdate' => $assessmentstartdate, + 'duedate' => $assessmentenddate, + ] + ); + + // Create test quiz 1. + $this->quiz1 = $dg->create_module( + 'quiz', + [ + 'course' => $this->course1->id, + 'name' => 'Test Quiz 1', + 'timeopen' => $assessmentstartdate, + 'timelimit' => 60, + 'timeclose' => $assessmentenddate, + ] + ); + + // Set up the SITS grade push. + $this->setup_sitsgradepush(); + } + + /** + * Set up the SITS grade push. + * + * @return void + * @throws \dml_exception|\coding_exception + */ + protected function setup_sitsgradepush(): void { + // Insert MABs. + tests_data_provider::import_sitsgradepush_grade_components(); + } + + /** + * Insert a test mapping. + * + * @param int $mabid + * @param int $courseid + * @param \stdClass $assessment + * @param string $modtype + * @return bool|int + * @throws \dml_exception + */ + protected function insert_mapping(int $mabid, int $courseid, \stdClass $assessment, string $modtype): bool|int { + global $DB; + + return $DB->insert_record('local_sitsgradepush_mapping', [ + 'courseid' => $courseid, + 'sourceid' => $assessment->cmid, + 'sourcetype' => 'mod', + 'moduletype' => $modtype, + 'componentgradeid' => $mabid, + 'reassessment' => 0, + 'enableextension' => extensionmanager::is_extension_enabled() ? 1 : 0, + 'timecreated' => time(), + 'timemodified' => time(), + ]); + } +} diff --git a/tests/extension/extension_test.php b/tests/extension/extension_test.php new file mode 100644 index 0000000..d7b972a --- /dev/null +++ b/tests/extension/extension_test.php @@ -0,0 +1,233 @@ +. + +namespace local_sitsgradepush; + +use local_sitsgradepush\extension\ec; + +defined('MOODLE_INTERNAL') || die(); +global $CFG; +require_once($CFG->dirroot . '/local/sitsgradepush/tests/fixtures/tests_data_provider.php'); +require_once($CFG->dirroot . '/local/sitsgradepush/tests/extension/extension_common.php'); + +/** + * Tests for the extension class. + * + * @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 + */ +final class extension_test extends extension_common { + + /** + * Test no overrides for mapping without extension enabled. + * + * @covers \local_sitsgradepush\extension\extension::parse_event_json + * @covers \local_sitsgradepush\extension\ec::process_extension + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_no_overrides_for_mapping_without_extension_enabled(): void { + global $DB; + // Disable the extension. + set_config('extension_enabled', '0', 'local_sitsgradepush'); + + // Set up the EC event data. + $message = $this->setup_for_ec_testing('LAWS0024A6UF', '001', $this->assign1, 'assign'); + + // Process the extension. + $ec = new ec(); + $ec->set_properties_from_aws_message($message); + $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); + } + + /** + * Test the EC extension process for moodle assignment. + * + * @covers \local_sitsgradepush\extension\extension::parse_event_json + * @covers \local_sitsgradepush\extension\ec::process_extension + * @covers \local_sitsgradepush\extension\extension::get_mappings_by_mab + * @covers \local_sitsgradepush\assessment\assign::apply_ec_extension + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_ec_process_extension_assign(): void { + global $DB; + + // Set up the EC event data. + $message = $this->setup_for_ec_testing('LAWS0024A6UF', '001', $this->assign1, 'assign'); + + // Process the extension by passing the JSON event data. + $ec = new ec(); + $ec->set_properties_from_aws_message($message); + $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. + $time = date('H:i:s', $this->assign1->duedate); + // Get the new date and time. + $newduedate = strtotime($ec->get_new_deadline() . ' ' . $time); + + $override = $DB->get_record('assign_overrides', ['assignid' => $this->assign1->id, 'userid' => $this->student1->id]); + $this->assertNotEmpty($override); + + // Check the new deadline is set correctly. + $this->assertEquals($newduedate, $override->duedate); + } + + /** + * Test the EC extension process for moodle quiz. + * + * @covers \local_sitsgradepush\extension\ec::process_extension + * @covers \local_sitsgradepush\extension\extension::get_mappings_by_mab + * @covers \local_sitsgradepush\assessment\quiz::apply_ec_extension + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_ec_process_extension_quiz(): void { + global $DB; + + // Set up the EC event data. + $message = $this->setup_for_ec_testing('LAWS0024A6UF', '002', $this->quiz1, 'quiz'); + + // Process the extension by passing the JSON event data. + $ec = new ec(); + $ec->set_properties_from_aws_message($message); + $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. + $time = date('H:i:s', $this->quiz1->timeclose); + + // Get the new date and time. + $newtimeclose = strtotime($ec->get_new_deadline() . ' ' . $time); + + $override = $DB->get_record('quiz_overrides', ['quiz' => $this->quiz1->id, 'userid' => $this->student1->id]); + $this->assertNotEmpty($override); + + // Check the new deadline is set correctly. + $this->assertEquals($newtimeclose, $override->timeclose); + } + + /** + * 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. Only 2 is returned as the max attempts is set to 2. + $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. + * + * @param string $mapcode The map code. + * @param string $mabseq The MAB sequence number. + * @param \stdClass $assessment The assessment object. + * @param string $modtype The module type. + * + * @return string|false + * @throws \dml_exception + */ + protected function setup_for_ec_testing(string $mapcode, string $mabseq, \stdClass $assessment, string $modtype): string|bool { + global $DB; + $mabid = $DB->get_field('local_sitsgradepush_mab', 'id', ['mapcode' => $mapcode, 'mabseq' => $mabseq]); + $this->insert_mapping($mabid, $this->course1->id, $assessment, $modtype); + + // Load the EC event data. + $ecjson = tests_data_provider::get_ec_event_data(); + $message = json_decode($ecjson, true); + + // Set the new deadline. + $newdeadline = strtotime('+3 days'); + $message['identifier'] = sprintf('%s-%s', $mapcode, $mabseq); + $message['new_deadline'] = date('Y-m-d', $newdeadline); + + return json_encode($message); + } +} diff --git a/tests/extension/sora_test.php b/tests/extension/sora_test.php new file mode 100644 index 0000000..8ea1fe7 --- /dev/null +++ b/tests/extension/sora_test.php @@ -0,0 +1,266 @@ +. + +namespace local_sitsgradepush; + +use local_sitsgradepush\extension\sora; + +defined('MOODLE_INTERNAL') || die(); +global $CFG; +require_once($CFG->dirroot . '/local/sitsgradepush/tests/fixtures/tests_data_provider.php'); +require_once($CFG->dirroot . '/local/sitsgradepush/tests/extension/extension_common.php'); + +/** + * Tests for the SORA override. + * + * @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 + */ +final class sora_test extends extension_common { + + /** + * Test no SORA override for non-exam assessments. + * + * @covers \local_sitsgradepush\assessment\assign::is_exam + * @covers \local_sitsgradepush\assessment\quiz::is_exam + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_no_sora_override_for_non_exam_assessment(): void { + global $DB; + + // Set up the SORA overrides. + $this->setup_for_sora_testing(); + + $startdate = strtotime('+1 hour'); + $enddate = strtotime('+7 days'); + + // Modify assignment so that open duration more than 5 hours, i.e. not exam. + $DB->update_record('assign', (object) [ + 'id' => $this->assign1->id, + 'allowsubmissionsfromdate' => $startdate, + 'duedate' => $enddate, + ]); + + // Modify quiz so that time limit more than 5 hours, i.e. not exam. + $DB->update_record('quiz', (object) [ + 'id' => $this->quiz1->id, + 'timelimit' => 2880, // 48 hours. + ]); + + // Get mappings. + $mappings = manager::get_manager()->get_assessment_mappings_by_courseid($this->course1->id); + $sora = new sora(); + $sora->set_properties_from_get_students_api(tests_data_provider::get_sora_testing_student_data()); + $sora->process_extension($mappings); + + // Test no SORA override for the assignment. + $override = $DB->record_exists('assign_overrides', ['assignid' => $this->assign1->id]); + $this->assertFalse($override); + + // Test no SORA override for the quiz. + $override = $DB->record_exists('quiz_overrides', ['quiz' => $this->quiz1->id]); + $this->assertFalse($override); + } + + /** + * Test no SORA override for past assessments. + * + * @covers \local_sitsgradepush\extension\sora::set_properties_from_get_students_api + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_no_sora_override_for_past_assessment(): void { + global $DB; + + // Set up the SORA overrides. + $this->setup_for_sora_testing(); + + // Modify assignment so that open duration more than 5 hours, i.e. not exam. + $DB->update_record('assign', (object) [ + 'id' => $this->assign1->id, + 'duedate' => strtotime('-1 day'), + ]); + + // Modify quiz so that time limit more than 5 hours, i.e. not exam. + $DB->update_record('quiz', (object) [ + 'id' => $this->quiz1->id, + 'timeclose' => strtotime('-1 day'), + ]); + + // Get mappings. + $mappings = manager::get_manager()->get_assessment_mappings_by_courseid($this->course1->id); + $sora = new sora(); + $sora->set_properties_from_get_students_api(tests_data_provider::get_sora_testing_student_data()); + $sora->process_extension($mappings); + + // Test no SORA override for the assignment. + $override = $DB->record_exists('assign_overrides', ['assignid' => $this->assign1->id]); + $this->assertFalse($override); + + // Test no SORA override for the quiz. + $override = $DB->record_exists('quiz_overrides', ['quiz' => $this->quiz1->id]); + $this->assertFalse($override); + } + + /** + * Test SORA override using data from AWS message. + * + * @covers \local_sitsgradepush\extension\extension::get_mappings_by_userid + * @covers \local_sitsgradepush\extension\sora::process_extension + * @covers \local_sitsgradepush\extension\sora::get_sora_group_id + * @covers \local_sitsgradepush\extension\sora::set_properties_from_aws_message + * @covers \local_sitsgradepush\assessment\assign::apply_sora_extension + * @covers \local_sitsgradepush\assessment\quiz::apply_sora_extension + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_sora_process_extension_from_aws(): void { + global $DB; + + // Set up the SORA overrides. + $this->setup_for_sora_testing(); + + // 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->get_mappings_by_userid($sora->get_userid())); + + // Test SORA override group exists. + $groupid = $DB->get_field('groups', 'id', ['name' => $sora->get_extension_group_name()]); + $this->assertNotEmpty($groupid); + + // Test user is added to the SORA group. + $groupmember = $DB->get_record('groups_members', ['groupid' => $groupid, 'userid' => $this->student1->id]); + $this->assertNotEmpty($groupmember); + + // Test group override set in the assignment. + $override = $DB + ->get_record('assign_overrides', ['assignid' => $this->assign1->id, 'userid' => null, 'groupid' => $groupid]); + $this->assertEquals($override->groupid, $groupid); + + // Test group override set in the quiz. + $override = $DB->get_record('quiz_overrides', ['quiz' => $this->quiz1->id, 'userid' => null, 'groupid' => $groupid]); + $this->assertEquals($override->groupid, $groupid); + } + + /** + * 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 + */ + public function test_update_sora_for_mapping_with_extension_off(): void { + global $DB; + + // 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: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: $mapping->id"]; + $log = $DB->get_record_sql($sql, $params); + $this->assertNotEmpty($log); + } + + /** + * Test the update SORA override for students in a mapping. + * It also tests the SORA override using the student data from assessment API + * and the SORA override group is deleted when the mapping is removed. + * + * @covers \local_sitsgradepush\extensionmanager::update_sora_for_mapping + * @covers \local_sitsgradepush\extensionmanager::delete_sora_overrides + * @covers \local_sitsgradepush\extensionmanager::get_default_sora_groups_ids_in_course + * @covers \local_sitsgradepush\manager::get_assessment_mappings_by_courseid + * @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(); + $manager = manager::get_manager(); + + // Process all mappings for SORA. + $mappings = $manager->get_assessment_mappings_by_courseid($this->course1->id); + foreach ($mappings as $mapping) { + extensionmanager::update_sora_for_mapping($mapping, [tests_data_provider::get_sora_testing_student_data()]); + } + + // Test SORA override group exists. + $groupid = $DB->get_field('groups', 'id', ['name' => sora::SORA_GROUP_PREFIX . '25']); + $this->assertNotEmpty($groupid); + + // Test user is added to the SORA group. + $groupmember = $DB->get_record('groups_members', ['groupid' => $groupid, 'userid' => $this->student1->id]); + $this->assertNotEmpty($groupmember); + + // Test group override set in the assignment. + $override = $DB + ->get_record('assign_overrides', ['assignid' => $this->assign1->id, 'userid' => null, 'groupid' => $groupid]); + $this->assertEquals($override->groupid, $groupid); + + // Test group override set in the quiz. + $override = $DB->get_record('quiz_overrides', ['quiz' => $this->quiz1->id, 'userid' => null, 'groupid' => $groupid]); + $this->assertEquals($override->groupid, $groupid); + + // Delete all mappings. + foreach ($mappings as $mapping) { + manager::get_manager()->remove_mapping($this->course1->id, $mapping->id); + } + + // Test SORA override group is deleted in the assignment. + $override = $DB->record_exists('assign_overrides', ['assignid' => $this->assign1->id]); + $this->assertFalse($override); + + // Test SORA override group is deleted in the quiz. + $override = $DB->record_exists('quiz_overrides', ['quiz' => $this->quiz1->id]); + $this->assertFalse($override); + } + + /** + * Set up the environment for SORA testing. + * @return void + * @throws \dml_exception + */ + protected function setup_for_sora_testing(): void { + global $DB; + $mabid = $DB->get_field('local_sitsgradepush_mab', 'id', ['mapcode' => 'LAWS0024A6UF', 'mabseq' => '001']); + $this->insert_mapping($mabid, $this->course1->id, $this->assign1, 'assign'); + + $mabid = $DB->get_field('local_sitsgradepush_mab', 'id', ['mapcode' => 'LAWS0024A6UF', 'mabseq' => '002']); + $this->insert_mapping($mabid, $this->course1->id, $this->quiz1, 'quiz'); + } +} diff --git a/tests/fixtures/ec_event_data.json b/tests/fixtures/ec_event_data.json new file mode 100644 index 0000000..8281b95 --- /dev/null +++ b/tests/fixtures/ec_event_data.json @@ -0,0 +1,5 @@ +{ + "identifier": "", + "student_code": "12345678", + "new_deadline": "" +} diff --git a/tests/fixtures/sora_event_data.json b/tests/fixtures/sora_event_data.json new file mode 100644 index 0000000..f45466c --- /dev/null +++ b/tests/fixtures/sora_event_data.json @@ -0,0 +1,43 @@ +{ + "identifier": "0b5aa7a5-5693-46ca-8669-f4e640392cf3", + "timestamp": "20241003T140010.357794UTC", + "event": { + "name": "person_sora", + "operation": "update", + "source": "sits" + }, + "changes": [ + { + "attribute": "person_sora['sora']['type']['code']", + "from": "REGREQ", + "to": "SORATEXT" + } + ], + "entity": { + "person_sora": { + "sora": [ + { + "identifier": "AAAHM03-000001", + "type": { + "code": "EXAM", + "name": "Examinations" + }, + "arrangement_record_sequence_number": "000001", + "accessibility_assessment_arrangement_sequence_number": "000001", + "approved_indicator": null, + "extra_duration": 30, + "rest_duration": 5, + "note": null, + "post_deadline_details": null, + "expiry_date": null, + "last_updated": null, + "person": { + "identifier": "AAAHM03", + "student_code": "12345678", + "userid": null + } + } + ] + } + } +} diff --git a/tests/fixtures/sora_test_students.json b/tests/fixtures/sora_test_students.json new file mode 100644 index 0000000..6e4f894 --- /dev/null +++ b/tests/fixtures/sora_test_students.json @@ -0,0 +1,9 @@ +{ + "code": "12345678", + "spr_code": "12345678/1", + "assessment": { + "resit_number": 0, + "sora_assessment_duration": 20, + "sora_rest_duration": 5 + } +} diff --git a/tests/fixtures/tests_data_provider.php b/tests/fixtures/tests_data_provider.php index 791560d..eb59932 100644 --- a/tests/fixtures/tests_data_provider.php +++ b/tests/fixtures/tests_data_provider.php @@ -170,6 +170,33 @@ public static function get_behat_submission_log_response(): array { return ["code" => 0, "message" => get_string('msg:submissionlogpushsuccess', 'sitsapiclient_easikit')]; } + /** + * Get the EC event data. + * + * @return string + */ + public static function get_ec_event_data(): string { + return file_get_contents(__DIR__ . "/ec_event_data.json"); + } + + /** + * Get the SORA event data. + * + * @return string + */ + public static function get_sora_event_data(): string { + return file_get_contents(__DIR__ . "/sora_event_data.json"); + } + + /** + * Get the SORA testing student data. + * + * @return array + */ + public static function get_sora_testing_student_data(): array { + return json_decode(file_get_contents(__DIR__ . "/sora_test_students.json"), true); + } + /** * Set a protected property. * diff --git a/tests/manager_test.php b/tests/manager_test.php index e678f0b..748b8f3 100644 --- a/tests/manager_test.php +++ b/tests/manager_test.php @@ -20,9 +20,7 @@ use cache; use context_course; use context_module; -use local_sitsgradepush\api\client; use local_sitsgradepush\api\client_factory; -use local_sitsgradepush\api\iclient; use local_sitsgradepush\api\irequest; use local_sitsgradepush\assessment\assessment; use local_sitsgradepush\assessment\assessmentfactory; @@ -31,6 +29,7 @@ defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/local/sitsgradepush/tests/fixtures/tests_data_provider.php'); +require_once(__DIR__ . '/base_test_class.php'); /** * Tests for the manager class. @@ -40,7 +39,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @author Alex Yeung */ -final class manager_test extends \advanced_testcase { +final class manager_test extends base_test_class { /** @var \local_sitsgradepush\manager|null Manager object */ private ?manager $manager; @@ -1271,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); } /** @@ -1584,34 +1583,4 @@ private function create_assignment_with_grade_and_submission(): \stdClass { return $assignmodule1; } - - /** - * Get api client for testing. - * - * @param bool $shouldthrowexception - * @param mixed|null $response - * - * @return \local_sitsgradepush\api\iclient - * @throws \coding_exception - * @throws \dml_exception - */ - private function get_apiclient_for_testing(bool $shouldthrowexception, mixed $response = null): iclient { - $apiclient = $this->createMock(client::class); - $apiclient->expects($this->any()) - ->method('get_client_name') - ->willReturn(get_string('pluginname', 'sitsapiclient_' . get_config('local_sitsgradepush', 'apiclient'))); - $apiclient->expects($this->any()) - ->method('build_request') - ->willReturn($this->createMock(irequest::class)); - if ($shouldthrowexception) { - $apiclient->expects($this->any()) - ->method('send_request') - ->will($this->throwException(new \moodle_exception('error:webclient', 'sitsapiclient_easikit'))); - } else { - $apiclient->expects($this->any()) - ->method('send_request') - ->willReturn($response); - } - return $apiclient; - } } diff --git a/version.php b/version.php index 99d9ac5..46e5569 100644 --- a/version.php +++ b/version.php @@ -27,8 +27,8 @@ $plugin->component = 'local_sitsgradepush'; $plugin->release = '0.1.0'; -$plugin->version = 2024101000; -$plugin->requires = 2023100900; +$plugin->version = 2024110102; +$plugin->requires = 2024042200; $plugin->maturity = MATURITY_ALPHA; $plugin->dependencies = [ 'block_portico_enrolments' => 2023012400,