diff --git a/classes/extension/ec.php b/classes/extension/ec.php index 1b4635c..bdc7316 100644 --- a/classes/extension/ec.php +++ b/classes/extension/ec.php @@ -17,6 +17,7 @@ namespace local_sitsgradepush\extension; use local_sitsgradepush\assessment\assessmentfactory; +use local_sitsgradepush\logger; /** * Class for extenuating circumstance (EC). @@ -34,19 +35,6 @@ class ec extends extension { /** @var string MAB identifier, e.g. CCME0158A6UF-001 */ protected string $mabidentifier; - /** - * Constructor. - * - * @param string $message - * @throws \dml_exception - */ - public function __construct(string $message) { - parent::__construct($message); - - // Set the EC properties that we need. - $this->set_ec_properties(); - } - /** * Returns the new deadline. * @@ -75,30 +63,40 @@ public function process_extension(): void { $assessment->apply_extension($this); } } catch (\Exception $e) { - // Consider logging the error here. - continue; + logger::log($e->getMessage(), null, "Mapping ID: $mapping->id"); } } } /** - * Set the EC properties. + * 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 $message * @return void - * @throws \dml_exception + * @throws \dml_exception|\moodle_exception */ - private function set_ec_properties(): void { - global $DB; + public function set_properties_from_aws_message(string $message): void { + // Decode the JSON message. + $messagedata = $this->parse_event_json($message); - // Find and set the user ID from the student. - $idnumber = $this->message->student_code; - $user = $DB->get_record('user', ['idnumber' => $idnumber], 'id', MUST_EXIST); - $this->userid = $user->id; + // Set the user ID of the student. + $this->set_userid($messagedata->student_code); // Set the MAB identifier. - $this->mabidentifier = $this->message->identifier; + $this->mabidentifier = $messagedata->identifier; // Set new deadline. - $this->newdeadline = $this->message->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 index 8167f1a..f09f96a 100644 --- a/classes/extension/extension.php +++ b/classes/extension/extension.php @@ -31,21 +31,27 @@ abstract class extension implements iextension { /** @var array Supported module types */ const SUPPORTED_MODULE_TYPES = ['assign', 'quiz']; - /** @var \stdClass Message from AWS */ - protected \stdClass $message; - /** @var int User ID */ protected int $userid; + /** @var bool Used to check if the extension data is set. */ + protected bool $dataisset = false; + /** - * Constructor. + * Set properties from JSON message like SORA / EC update message from AWS. * * @param string $message - * @throws \Exception + * @return void */ - public function __construct(string $message) { - $this->message = $this->parse_event_json($message); - } + abstract public function set_properties_from_aws_message(string $message): 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. @@ -156,4 +162,19 @@ protected function parse_event_json(string $message): \stdClass { } 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/sora.php b/classes/extension/sora.php index 3e522c1..b8b2e93 100644 --- a/classes/extension/sora.php +++ b/classes/extension/sora.php @@ -17,6 +17,7 @@ namespace local_sitsgradepush\extension; use local_sitsgradepush\assessment\assessmentfactory; +use local_sitsgradepush\logger; /** * Class for Summary of Reasonable Adjustments (SORA). @@ -31,27 +32,15 @@ class sora extends extension { /** @var string Prefix used to create SORA groups */ const SORA_GROUP_PREFIX = 'DEFAULT-SORA-'; - /** @var \stdClass Event data in the AWS message */ - protected \stdClass $eventdata; + /** @var int Extra duration in minutes per hour */ + protected int $extraduration; - /** @var \stdClass SORA data in the AWS message */ - protected \stdClass $soradata; + /** @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; - /** - * Constructor. - * - * @param string $message - */ - public function __construct(string $message) { - parent::__construct($message); - - // Set the SORA properties that we need. - $this->set_sora_properties(); - } - /** * Return the whole time extension in seconds, including extra and rest duration. * @@ -67,7 +56,7 @@ public function get_time_extension(): int { * @return int */ public function get_extra_duration(): int { - return (int) $this->soradata->extra_duration; + return $this->extraduration; } /** @@ -76,7 +65,7 @@ public function get_extra_duration(): int { * @return int */ public function get_rest_duration(): int { - return (int) $this->soradata->rest_duration; + return $this->restduration; } /** @@ -106,7 +95,7 @@ public function get_sora_group_id(int $courseid, int $userid): int { $newgroup->description = ''; $newgroup->enrolmentkey = ''; $newgroup->picture = 0; - $newgroup->visibility = GROUPS_VISIBILITY_MEMBERS; + $newgroup->visibility = GROUPS_VISIBILITY_OWN; $newgroup->hidepicture = 0; $newgroup->timecreated = time(); $newgroup->timemodified = time(); @@ -133,6 +122,57 @@ 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 $message + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function set_properties_from_aws_message(string $message): void { + + // Decode the JSON message. + $messagedata = $this->parse_event_json($message); + + // Check the message is valid. + if (empty($messagedata->entity->person_sora->sora[0])) { + throw new \moodle_exception('error:invalid_message', 'local_sitsgradepush', '', null, $message); + } + + $soradata = $messagedata->entity->person_sora->sora[0]; + + // Set the user ID of the student. + $this->set_userid($soradata->person->student_code); + + // Set properties. + $this->extraduration = (int) $soradata->extra_duration; + $this->restduration = (int) $soradata->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; + } + + /** + * 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. * @@ -141,6 +181,10 @@ public function get_extension_group_name(): string { * @throws \dml_exception */ public function process_extension(): void { + if (!$this->dataisset) { + throw new \coding_exception('error:extensiondataisnotset', 'local_sitsgradepush'); + } + // Get all mappings for the student. $mappings = $this->get_mappings_by_userid($this->get_userid()); @@ -157,8 +201,7 @@ public function process_extension(): void { $assessment->apply_extension($this); } } catch (\Exception $e) { - // Consider logging the exception here. - continue; + logger::log($e->getMessage()); } } } @@ -194,23 +237,13 @@ protected function remove_user_from_previous_sora_groups(int $newgroupid, int $c } /** - * Set the SORA properties. + * Calculate the time extension in seconds. + * + * @param int $extraduration Extra duration in minutes. + * @param int $restduration Rest duration in minutes. + * @return int */ - private function set_sora_properties(): void { - global $DB; - - // Set the event data. - $this->eventdata = $this->message->event; - - // Set the SORA data. - $this->soradata = $this->message->entity->metadata->sora; - - // Find and set the user ID of the student. - $idnumber = $this->soradata->person->student_code; - $user = $DB->get_record('user', ['idnumber' => $idnumber], 'id', MUST_EXIST); - $this->userid = $user->id; - - // Set the time extension in seconds. - $this->timeextension = ($this->get_extra_duration() + $this->get_rest_duration()) * MINSECS; + private function calculate_time_extension(int $extraduration, int $restduration): int { + return ($extraduration + $restduration) * MINSECS; } } diff --git a/classes/extensionmanager.php b/classes/extensionmanager.php new file mode 100644 index 0000000..90b29cd --- /dev/null +++ b/classes/extensionmanager.php @@ -0,0 +1,66 @@ +. + +namespace local_sitsgradepush; + +use local_sitsgradepush\extension\sora; + +/** + * 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 int $mapid + * @return void + * @throws \dml_exception + */ + public static function update_sora_for_mapping(int $mapid): void { + try { + // Find the SITS assessment component. + $mab = manager::get_manager()->get_mab_by_mapping_id($mapid); + + // Throw exception if the SITS assessment component is not found. + if (!$mab) { + throw new \moodle_exception('error:mab_not_found', 'local_sitsgradepush', '', $mapid); + } + + // Get students information for that assessment component. + $students = manager::get_manager()->get_students_from_sits($mab); + + // If no students found, nothing to do. + if (empty($students)) { + return; + } + + // Process SORA extension for each student. + foreach ($students as $student) { + $sora = new sora(); + $sora->set_properties_from_get_students_api($student); + $sora->process_extension(); + } + } catch (\Exception $e) { + logger::log($e->getMessage(), null, "Mapping ID: $mapid"); + } + } +} diff --git a/classes/manager.php b/classes/manager.php index fa49ee5..9fea0fd 100644 --- a/classes/manager.php +++ b/classes/manager.php @@ -474,7 +474,7 @@ 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'); + assesstype::update_assess_type($existingmapping, assesstype::ACTION_UNLOCK); } // Insert new mapping. @@ -492,7 +492,9 @@ public function save_assessment_mapping(\stdClass $data): int|bool { $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; } diff --git a/classes/observer.php b/classes/observer.php index 4c6f6b9..4d9b2ff 100644 --- a/classes/observer.php +++ b/classes/observer.php @@ -16,6 +16,7 @@ use local_sitsgradepush\cachemanager; use local_sitsgradepush\manager; +use local_sitsgradepush\taskmanager; /** * Class for local_sitsgradepush observer. @@ -83,13 +84,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 there are students in the SITS assessment. + if (!empty($manager->get_students_from_sits($mab))) { + taskmanager::add_process_extensions_adhoc_task($data['other']['mappingid']); } } } diff --git a/classes/task/process_extensions.php b/classes/task/process_extensions.php new file mode 100644 index 0000000..d8ded8d --- /dev/null +++ b/classes/task/process_extensions.php @@ -0,0 +1,65 @@ +. + +namespace local_sitsgradepush\task; + +use core\task\adhoc_task; +use local_sitsgradepush\extensionmanager; +use local_sitsgradepush\logger; + +/** + * 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 extends adhoc_task { + + /** + * Return name of the task. + * + * @return string + * @throws \coding_exception + */ + public function get_name() { + return get_string('task:processextensions', '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'); + } + + // Process SORA extension. + extensionmanager::update_sora_for_mapping($data->mapid); + + // 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 1937575..58e7c65 100644 --- a/classes/taskmanager.php +++ b/classes/taskmanager.php @@ -17,7 +17,9 @@ namespace local_sitsgradepush; use context_user; +use core\task\manager as coretaskmanager; use local_sitsgradepush\assessment\assessmentfactory; +use local_sitsgradepush\task\process_extensions; /** * Manager class which handles push task. @@ -402,4 +404,16 @@ 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 mapping. + * + * @param int $mapid + * @return void + */ + public static function add_process_extensions_adhoc_task(int $mapid): void { + $task = new process_extensions(); + $task->set_custom_data((object)['mapid' => $mapid]); + coretaskmanager::queue_adhoc_task($task); + } } diff --git a/lang/en/local_sitsgradepush.php b/lang/en/local_sitsgradepush.php index b455038..db6f0a6 100644 --- a/lang/en/local_sitsgradepush.php +++ b/lang/en/local_sitsgradepush.php @@ -90,10 +90,12 @@ $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: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:emptyresponse'] = 'Empty response received when calling {$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.'; @@ -101,6 +103,7 @@ $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_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'; @@ -222,6 +225,7 @@ $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:processextensions'] = 'Process SORA and EC extensions'; $string['task:pushtask:name'] = 'Schedule Transfer Task'; $string['task:requested:success'] = 'Transfer task requested successfully'; $string['task:status:completed'] = 'completed'; diff --git a/tests/extension/extension_test.php b/tests/extension/extension_test.php index f98f69f..4b676e7 100644 --- a/tests/extension/extension_test.php +++ b/tests/extension/extension_test.php @@ -16,6 +16,9 @@ namespace local_sitsgradepush; +use local_sitsgradepush\extension\ec; +use local_sitsgradepush\extension\sora; + defined('MOODLE_INTERNAL') || die(); global $CFG; require_once($CFG->dirroot . '/local/sitsgradepush/tests/fixtures/tests_data_provider.php'); @@ -111,6 +114,7 @@ protected function setUp(): void { * @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; @@ -121,7 +125,8 @@ public function test_no_overrides_for_mapping_without_extension_enabled(): void $message = $this->setup_for_ec_testing('LAWS0024A6UF', '001', $this->assign1, 'assign'); // Process the extension. - $ec = new extension\ec($message); + $ec = new ec(); + $ec->set_properties_from_aws_message($message); $ec->process_extension(); $override = $DB->get_record('assign_overrides', ['assignid' => $this->assign1->id, 'userid' => $this->student1->id]); @@ -137,6 +142,7 @@ public function test_no_overrides_for_mapping_without_extension_enabled(): void * @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; @@ -145,7 +151,8 @@ public function test_ec_process_extension_assign(): void { $message = $this->setup_for_ec_testing('LAWS0024A6UF', '001', $this->assign1, 'assign'); // Process the extension by passing the JSON event data. - $ec = new extension\ec($message); + $ec = new ec(); + $ec->set_properties_from_aws_message($message); $ec->process_extension(); // Calculate the new deadline. @@ -169,6 +176,7 @@ public function test_ec_process_extension_assign(): void { * @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; @@ -177,7 +185,8 @@ public function test_ec_process_extension_quiz(): void { $message = $this->setup_for_ec_testing('LAWS0024A6UF', '002', $this->quiz1, 'quiz'); // Process the extension by passing the JSON event data. - $ec = new extension\ec($message); + $ec = new ec(); + $ec->set_properties_from_aws_message($message); $ec->process_extension(); // Calculate the new deadline. @@ -204,6 +213,7 @@ public function test_ec_process_extension_quiz(): void { * @covers \local_sitsgradepush\assessment\quiz::apply_sora_extension * @throws \coding_exception * @throws \dml_exception + * @throws \moodle_exception */ public function test_sora_process_extension(): void { global $DB; @@ -212,7 +222,8 @@ public function test_sora_process_extension(): void { $this->setup_for_sora_testing(); // Process the extension by passing the JSON event data. - $sora = new extension\sora(tests_data_provider::get_sora_event_data()); + $sora = new sora(); + $sora->set_properties_from_aws_message(tests_data_provider::get_sora_event_data()); $sora->process_extension(); // Test SORA override group exists. diff --git a/tests/fixtures/sora_event_data.json b/tests/fixtures/sora_event_data.json index 6d4e72c..f45466c 100644 --- a/tests/fixtures/sora_event_data.json +++ b/tests/fixtures/sora_event_data.json @@ -1,47 +1,43 @@ { - "identifier": "", - "sequence_number": "", - "timestamp": "2022-10-13T15:57:02.089Z", + "identifier": "0b5aa7a5-5693-46ca-8669-f4e640392cf3", + "timestamp": "20241003T140010.357794UTC", "event": { - "name": "person-sora", - "type": "update", - "source": "SITS", - "changes": [ - { - "attribute": "sora.extra_duration", - "from": "15", - "to": "30" - }, - { - "attribute": "sora.person.identifier", - "from": "ABCDE12", - "to": "EDCBA21" - } - ] + "name": "person_sora", + "operation": "update", + "source": "sits" }, + "changes": [ + { + "attribute": "person_sora['sora']['type']['code']", + "from": "REGREQ", + "to": "SORATEXT" + } + ], "entity": { - "metadata": { - "sora": { - "identifier": "", - "type": { - "code": "", - "name": "" - }, - "accessibility_assessment_arrangement_sequence_number": "", - "arrangement_record_sequence_number": "", - "approved_indicator": "", - "extra_duration": "30", - "rest_duration": "5", - "post_deadline_details": "", - "expiry_date": "", - "last_updated": "", - "note": "", - "person": { - "identifier": "ABCDE12", - "student_code": "12345678", - "userid": "123456" + "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 + } } - } + ] } } }