From 966c868d5e1fe4641ce8c9aa20255d3c77e1e435 Mon Sep 17 00:00:00 2001 From: Alex Yeung Date: Mon, 30 Sep 2024 13:37:27 +0100 Subject: [PATCH] CTP-3412 & CTP-3413 Create overrides for ECs & SORAs --- .github/workflows/moodle-ci.yml | 3 - classes/assessment/activity.php | 14 ++ classes/assessment/assessment.php | 62 ++++- classes/assessment/assign.php | 181 +++++++++++++- classes/assessment/quiz.php | 99 +++++++- classes/extension/ec.php | 104 ++++++++ classes/extension/extension.php | 159 +++++++++++++ classes/extension/iextension.php | 32 +++ classes/extension/sora.php | 216 +++++++++++++++++ classes/manager.php | 1 + db/install.xml | 3 +- db/upgrade.php | 16 ++ lang/en/local_sitsgradepush.php | 9 + settings.php | 9 + templates/setting_configdate.mustache | 49 ++++ tests/extension/extension_test.php | 314 +++++++++++++++++++++++++ tests/fixtures/ec_event_data.json | 5 + tests/fixtures/sora_event_data.json | 47 ++++ tests/fixtures/tests_data_provider.php | 18 ++ version.php | 4 +- 20 files changed, 1334 insertions(+), 11 deletions(-) create mode 100644 classes/extension/ec.php create mode 100644 classes/extension/extension.php create mode 100644 classes/extension/iextension.php create mode 100644 classes/extension/sora.php create mode 100644 templates/setting_configdate.mustache create mode 100644 tests/extension/extension_test.php create mode 100644 tests/fixtures/ec_event_data.json create mode 100644 tests/fixtures/sora_event_data.json 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 ddaa6b3..278d107 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,6 +33,9 @@ abstract class activity extends assessment { /** @var \stdClass Course module object */ public \stdClass $coursemodule; + /** @var module Context module */ + protected module $context; + /** * Constructor. * @@ -39,6 +43,7 @@ abstract class activity extends assessment { */ public function __construct(\stdClass $coursemodule) { $this->coursemodule = $coursemodule; + $this->context = \context_module::instance($coursemodule->id); parent::__construct(assessmentfactory::SOURCETYPE_MOD, $coursemodule->id); } @@ -103,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 4640fca..82c0adf 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; /** @@ -52,6 +55,27 @@ 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) { + $this->apply_sora_extension($extension); + } + } + /** * Get the source id. * @@ -196,6 +220,19 @@ 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:dates_not_set'); + } + + return $this->set_validity_result(true); + } + /** * Set validity result. * @@ -224,11 +261,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 8aebfc2..cabf6a6 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,14 +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 { - $context = \context_module::instance($this->coursemodule->id); - return get_enrolled_users($context, 'mod/assign:submit'); + return get_enrolled_users($this->get_module_context(), 'mod/assign:submit'); } /** @@ -53,4 +66,168 @@ public function get_start_date(): ?int { public function get_end_date(): ?int { return $this->sourceinstance->duedate; } + + /** + * 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 2213dd7..aa84c53 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,14 +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 { - $context = \context_module::instance($this->coursemodule->id); - return get_enrolled_users($context, 'mod/quiz:attempt'); + return get_enrolled_users($this->get_module_context(), 'mod/quiz:attempt'); } /** @@ -52,4 +66,85 @@ public function get_start_date(): ?int { public function get_end_date(): ?int { return $this->get_source_instance()->timeclose; } + + /** + * 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/extension/ec.php b/classes/extension/ec.php new file mode 100644 index 0000000..1b4635c --- /dev/null +++ b/classes/extension/ec.php @@ -0,0 +1,104 @@ +. + +namespace local_sitsgradepush\extension; + +use local_sitsgradepush\assessment\assessmentfactory; + +/** + * 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; + + /** + * 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. + * + * @return string + */ + public function get_new_deadline(): string { + return $this->newdeadline; + } + + /** + * Process the extension. + */ + public function process_extension(): void { + // Get all mappings for the SITS assessment. + // We only allow one mapping per SITS assessment for now. + $mappings = $this->get_mappings_by_mab($this->get_mab_identifier()); + + 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) { + // Consider logging the error here. + continue; + } + } + } + + /** + * Set the EC properties. + * + * @return void + * @throws \dml_exception + */ + private function set_ec_properties(): void { + global $DB; + + // 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 MAB identifier. + $this->mabidentifier = $this->message->identifier; + + // Set new deadline. + $this->newdeadline = $this->message->new_deadline; + } +} diff --git a/classes/extension/extension.php b/classes/extension/extension.php new file mode 100644 index 0000000..8167f1a --- /dev/null +++ b/classes/extension/extension.php @@ -0,0 +1,159 @@ +. + +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 \stdClass Message from AWS */ + protected \stdClass $message; + + /** @var int User ID */ + protected int $userid; + + /** + * Constructor. + * + * @param string $message + * @throws \Exception + */ + public function __construct(string $message) { + $this->message = $this->parse_event_json($message); + } + + /** + * 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; + } + + /** + * Get all the assessment mappings by MAB identifier. + * + * @param string $mabidentifier + * @return array + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + */ + protected 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 + */ + protected 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 (empty($messageobject)) { + throw new \Exception('Invalid message data'); + } + return $messageobject; + } +} diff --git a/classes/extension/iextension.php b/classes/extension/iextension.php new file mode 100644 index 0000000..c494246 --- /dev/null +++ b/classes/extension/iextension.php @@ -0,0 +1,32 @@ +. + +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. + */ + public function process_extension(): void; +} diff --git a/classes/extension/sora.php b/classes/extension/sora.php new file mode 100644 index 0000000..3e522c1 --- /dev/null +++ b/classes/extension/sora.php @@ -0,0 +1,216 @@ +. + +namespace local_sitsgradepush\extension; + +use local_sitsgradepush\assessment\assessmentfactory; + +/** + * 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 \stdClass Event data in the AWS message */ + protected \stdClass $eventdata; + + /** @var \stdClass SORA data in the AWS message */ + protected \stdClass $soradata; + + /** @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. + * + * @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 (int) $this->soradata->extra_duration; + } + + /** + * Return the rest duration in minutes. + * + * @return int + */ + public function get_rest_duration(): int { + return (int) $this->soradata->rest_duration; + } + + /** + * 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_MEMBERS; + $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()); + } + + /** + * Process the extension. + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + */ + public function process_extension(): void { + // Get all mappings for the student. + $mappings = $this->get_mappings_by_userid($this->get_userid()); + + // No mappings found. + if (empty($mappings)) { + 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) { + // Consider logging the exception here. + continue; + } + } + } + + /** + * 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); + } + } + } + + /** + * Set the SORA properties. + */ + 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; + } +} diff --git a/classes/manager.php b/classes/manager.php index 159b98c..9085180 100644 --- a/classes/manager.php +++ b/classes/manager.php @@ -487,6 +487,7 @@ public function save_assessment_mapping(\stdClass $data): int|bool { } $record->componentgradeid = $data->componentgradeid; $record->reassessment = $data->reassessment; + $record->enableextension = get_config('local_sitsgradepush', 'extension_enabled') ? 1 : 0; $record->timecreated = time(); $record->timemodified = time(); diff --git a/db/install.xml b/db/install.xml index bca1c15..3f8f236 100644 --- a/db/install.xml +++ b/db/install.xml @@ -1,5 +1,5 @@ - @@ -14,6 +14,7 @@ + diff --git a/db/upgrade.php b/db/upgrade.php index 11258c5..904ab1e 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -519,5 +519,21 @@ function xmldb_local_sitsgradepush_upgrade($oldversion) { } } + 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'); + } + + return true; } diff --git a/lang/en/local_sitsgradepush.php b/lang/en/local_sitsgradepush.php index 4d67026..fbf1ece 100644 --- a/lang/en/local_sitsgradepush.php +++ b/lang/en/local_sitsgradepush.php @@ -77,19 +77,25 @@ $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:coursemodulenotfound'] = 'Course module not found.'; +$string['error:dateformat'] = 'Date must be in format YYYY-MM-DD'; $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:extensionstartdate'] = 'Extension start date 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.'; @@ -118,6 +124,7 @@ $string['error:reassessmentdisabled'] = 'Re-assessment marks transfer is disabled.'; $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.'; @@ -186,6 +193,8 @@ $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'; diff --git a/settings.php b/settings.php index d38f572..e27528a 100644 --- a/settings.php +++ b/settings.php @@ -26,6 +26,7 @@ use local_sitsgradepush\manager; use local_sitsgradepush\plugininfo\sitsapiclient; +use local_sitsgradepush\settings\admin_setting_configdate; defined('MOODLE_INTERNAL') || die(); @@ -149,6 +150,14 @@ 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' + )); } $subplugins = core_plugin_manager::instance()->get_plugins_of_type('sitsapiclient'); diff --git a/templates/setting_configdate.mustache b/templates/setting_configdate.mustache new file mode 100644 index 0000000..513baac --- /dev/null +++ b/templates/setting_configdate.mustache @@ -0,0 +1,49 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template local_sitsgradepush/setting_configdate + + Admin date setting template. + + Context variables required for this template: + * type - input type + * name - form element name + * id - element id + * value - element value + * size - element size + * forceltr - always display as ltr + * attributes - list of additional attributes containing name, value + * readonly - bool + + Example context (json): + { + "type": "text", + "name": "test", + "id": "test0", + "value": "2022-01-01", + "size": "21", + "forceltr": false, + "readonly": false, + "attributes": [ { "name": "readonly", "value": "readonly" } ] + } +}} +{{! + Setting configtext. +}} +
+ +
diff --git a/tests/extension/extension_test.php b/tests/extension/extension_test.php new file mode 100644 index 0000000..f98f69f --- /dev/null +++ b/tests/extension/extension_test.php @@ -0,0 +1,314 @@ +. + +namespace local_sitsgradepush; + +defined('MOODLE_INTERNAL') || die(); +global $CFG; +require_once($CFG->dirroot . '/local/sitsgradepush/tests/fixtures/tests_data_provider.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 \advanced_testcase { + + /** @var \stdClass $course1 Default test course 1 */ + private \stdClass $course1; + + /** @var \stdClass Default test student 1 */ + private \stdClass $student1; + + /** @var \stdClass Default test assignment 1 */ + private \stdClass $assign1; + + /** @var \stdClass Default test quiz 1*/ + private \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(); + + // 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 day'); + $assessmentenddate = strtotime('+2 days'); + + // 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(); + } + + /** + * 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 + */ + 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 extension\ec($message); + $ec->process_extension(); + + $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 + */ + 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 extension\ec($message); + $ec->process_extension(); + + // 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 + */ + 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 extension\ec($message); + $ec->process_extension(); + + // 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 SORA extension process. + * + * @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\assessment\assign::apply_sora_extension + * @covers \local_sitsgradepush\assessment\quiz::apply_sora_extension + * @throws \coding_exception + * @throws \dml_exception + */ + public function test_sora_process_extension(): void { + global $DB; + + // Insert mappings for SORA. + $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->process_extension(); + + // 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); + } + + /** + * 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); + } + + /** + * 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'); + } + + /** + * Set up the SITS grade push. + * + * @return void + * @throws \dml_exception|\coding_exception + */ + private 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 + */ + private 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' => get_config('local_sitsgradepush', 'extension_enabled') ? 1 : 0, + 'timecreated' => time(), + 'timemodified' => time(), + ]); + } +} 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..6d4e72c --- /dev/null +++ b/tests/fixtures/sora_event_data.json @@ -0,0 +1,47 @@ +{ + "identifier": "", + "sequence_number": "", + "timestamp": "2022-10-13T15:57:02.089Z", + "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" + } + ] + }, + "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" + } + } + } + } +} diff --git a/tests/fixtures/tests_data_provider.php b/tests/fixtures/tests_data_provider.php index 791560d..28ca9f8 100644 --- a/tests/fixtures/tests_data_provider.php +++ b/tests/fixtures/tests_data_provider.php @@ -170,6 +170,24 @@ 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"); + } + /** * Set a protected property. * diff --git a/version.php b/version.php index eb73f20..12897fa 100644 --- a/version.php +++ b/version.php @@ -27,8 +27,8 @@ $plugin->component = 'local_sitsgradepush'; $plugin->release = '0.1.0'; -$plugin->version = 2024091200; -$plugin->requires = 2023100900; +$plugin->version = 2024101100; +$plugin->requires = 2024042200; $plugin->maturity = MATURITY_ALPHA; $plugin->dependencies = [ 'block_portico_enrolments' => 2023012400,