diff --git a/.github/workflows/moodle-ci.yml b/.github/workflows/moodle-ci.yml index 34b94e7..7fc4d61 100644 --- a/.github/workflows/moodle-ci.yml +++ b/.github/workflows/moodle-ci.yml @@ -10,7 +10,7 @@ jobs: services: postgres: - image: postgres:13 + image: postgres:14 env: POSTGRES_USER: 'postgres' POSTGRES_HOST_AUTH_METHOD: 'trust' diff --git a/classes/assesstype.php b/classes/assesstype.php index e2784a6..e74a81c 100644 --- a/classes/assesstype.php +++ b/classes/assesstype.php @@ -62,23 +62,19 @@ public static function update_assess_type(int|\stdClass $mapping, string $action return; } - // Mapped assessment is a course module, set grdade item ID to 0. - if ($mapping->sourcetype === assessmentfactory::SOURCETYPE_MOD) { - $cmid = $mapping->sourceid; - $gradeitemid = 0; - } else { - // Mapped assessment is a gradebook item or category, set course module ID to 0. - $cmid = 0; - $assessment = assessmentfactory::get_assessment($mapping->sourcetype, $mapping->sourceid); - $gradeitems = $assessment->get_grade_items(); - $gradeitemid = $gradeitems[0]->id; - } + // Set course module ID if the mapped assessment is a course module, otherwise set to 0. + $cmid = $mapping->sourcetype === assessmentfactory::SOURCETYPE_MOD ? $mapping->sourceid : 0; + + // Everything that is not a course module is a grade item or category. + $gradeitemid = $cmid ? 0 : assessmentfactory::get_assessment($mapping->sourcetype, $mapping->sourceid) + ->get_grade_items()[0]->id; - // Set assessment type and lock status. - if ($action === self::ACTION_LOCK) { - assess_type::update_type($mapping->courseid, assess_type::ASSESS_TYPE_SUMMATIVE, $cmid, $gradeitemid, 1); - } else if ($action === self::ACTION_UNLOCK) { - assess_type::update_type($mapping->courseid, assess_type::ASSESS_TYPE_SUMMATIVE, $cmid, $gradeitemid, 0); + $lockstatus = $action === self::ACTION_LOCK ? 1 : 0; + assess_type::update_type($mapping->courseid, assess_type::ASSESS_TYPE_SUMMATIVE, $cmid, $gradeitemid, $lockstatus); + + // Update assess type for items (grade items or activities) under the grade category. + if ($mapping->sourcetype === assessmentfactory::SOURCETYPE_GRADE_CATEGORY) { + self::update_assess_type_items_under_gradecategory($action, $mapping->sourceid); } } catch (\Exception $e) { logger::log('Failed to update assessment type and lock status.', null, null, $e->getMessage()); @@ -96,4 +92,143 @@ public static function is_assess_type_installed(): bool { 'local_assess_type' ); } + + /** + * Update assessment type and lock status for grade item when it is updated outside marks transfer. + * + * @param \core\event\grade_item_updated $event + * @return void + * @throws \coding_exception + * @throws \dml_exception + */ + public static function grade_item_updated(\core\event\grade_item_updated $event): void { + global $DB; + + // Skip if assessment type plugin is not installed. + if (!self::is_assess_type_installed()) { + return; + } + + $gradeitem = $event->get_record_snapshot('grade_items', $event->objectid); + + // Determine source type and source ID based on item type. + switch ($gradeitem->itemtype) { + case 'manual': + $sourcetype = assessmentfactory::SOURCETYPE_GRADE_ITEM; + $sourceid = $gradeitemid = $gradeitem->id; + $cmid = 0; + break; + case 'mod': + $sourcetype = assessmentfactory::SOURCETYPE_MOD; + $sourceid = $cmid = get_coursemodule_from_instance( + $gradeitem->itemmodule, + $gradeitem->iteminstance, + $gradeitem->courseid + )->id; + $gradeitemid = 0; + break; + default: + return; + } + + // Skip if grade item or activity is mapped. It will be handled by the mapping / unmapping actions. + if ($DB->record_exists(manager::TABLE_ASSESSMENT_MAPPING, ['sourcetype' => $sourcetype, 'sourceid' => $sourceid])) { + return; + } + + // Depending on the grade item's category. If the category is mapped, mark it summative and lock it. + // Otherwise, unlock it. + $action = $DB->record_exists( + manager::TABLE_ASSESSMENT_MAPPING, + ['sourcetype' => assessmentfactory::SOURCETYPE_GRADE_CATEGORY, 'sourceid' => $gradeitem->categoryid] + ) ? self::ACTION_LOCK : self::ACTION_UNLOCK; + + // We only want to unlock grade items or activities that are summative and have been locked. + if ($action === self::ACTION_UNLOCK) { + $assesstyperecords = assess_type::get_assess_type_records_by_courseid( + $gradeitem->courseid, + assess_type::ASSESS_TYPE_SUMMATIVE + ); + + if (empty(array_filter( + $assesstyperecords, + fn($record) => $record->cmid == $cmid && $record->gradeitemid == $gradeitemid && $record->locked)) + ) { + return; + } + } + + self::update_assess_type_items_under_gradecategory($action, $gradeitem->categoryid, $gradeitem); + } + + /** + * Update assess type for grade items and activities under a grade category. + * + * @param string $action + * @param int $categoryid + * @param \stdClass|null $gradeitem + * @return void + * @throws \coding_exception + * @throws \dml_exception + */ + private static function update_assess_type_items_under_gradecategory( + string $action, + int $categoryid, + ?\stdClass $gradeitem = null + ): void { + $lockstatus = $action === self::ACTION_LOCK ? 1 : 0; + $gradeitems = $gradeitem ? [$gradeitem] : \grade_item::fetch_all(['categoryid' => $categoryid]); + + foreach ($gradeitems as $item) { + if (!in_array($item->itemtype, ['mod', 'manual'])) { + continue; + } + + // Workshop can have multiple grade items under the same category. Only unlock the workshop if it is the + // only grade item under the category. + if ($action === self::ACTION_UNLOCK && $item->itemmodule === 'workshop') { + if (!self::should_unlock_workshop($item->iteminstance)) { + continue; + } + } + + $cmid = $item->itemtype === 'mod' + ? get_coursemodule_from_instance($item->itemmodule, $item->iteminstance, $item->courseid)->id + : 0; + $gradeitemid = $item->itemtype === 'manual' ? $item->id : 0; + + assess_type::update_type( + $item->courseid, + assess_type::ASSESS_TYPE_SUMMATIVE, + $cmid, + $gradeitemid, + $lockstatus + ); + } + } + + /** + * Check if the workshop should be unlocked. A workshop should be unlocked if there is no other grade item under + * a category that has been mapped. + * + * @param int $workshopinstanceid Workshop instance ID. + * @return bool + * @throws \dml_exception + */ + private static function should_unlock_workshop(int $workshopinstanceid): bool { + global $DB; + // Check if there is any grade item under the workshop that has category mapped. + $sql = "SELECT gi.id + FROM {grade_items} gi + JOIN {". manager::TABLE_ASSESSMENT_MAPPING . "} m ON gi.categoryid = m.sourceid AND m.sourcetype = :sourcetype + WHERE gi.itemmodule = :itemmodule AND gi.iteminstance = :workshopinstanceid"; + + $params = [ + 'sourcetype' => assessmentfactory::SOURCETYPE_GRADE_CATEGORY, + 'itemmodule' => 'workshop', + 'workshopinstanceid' => $workshopinstanceid, + ]; + + return empty($DB->get_records_sql($sql, $params)); + } } diff --git a/classes/manager.php b/classes/manager.php index 1625c26..f8d778a 100644 --- a/classes/manager.php +++ b/classes/manager.php @@ -1457,7 +1457,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 +1468,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]); + + // Unlock the Moodle assessment in the local_assess_type plugin. + assesstype::update_assess_type($mapping, assesstype::ACTION_UNLOCK); } /** diff --git a/classes/observer.php b/classes/observer.php index 4c6f6b9..bba853b 100644 --- a/classes/observer.php +++ b/classes/observer.php @@ -14,6 +14,8 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . +use local_sitsgradepush\assessment\assessmentfactory; +use local_sitsgradepush\assesstype; use local_sitsgradepush\cachemanager; use local_sitsgradepush\manager; @@ -92,4 +94,14 @@ public static function assessment_mapped(\local_sitsgradepush\event\assessment_m cachemanager::purge_cache(cachemanager::CACHE_AREA_STUDENTSPR, $key); } } + + /** + * Handle the grade item updated event. + * + * @param \core\event\grade_item_updated $event + * @return void + */ + public static function grade_item_updated(\core\event\grade_item_updated $event): void { + assesstype::grade_item_updated($event); + } } diff --git a/db/events.php b/db/events.php index 1320645..d4f3a0e 100644 --- a/db/events.php +++ b/db/events.php @@ -50,4 +50,8 @@ 'callback' => 'local_sitsgradepush_observer::assessment_mapped', 'priority' => 200, ], + [ + 'eventname' => '\core\event\grade_item_updated', + 'callback' => 'local_sitsgradepush_observer::grade_item_updated', + ], ]; diff --git a/tests/assesstype/assesstype_test.php b/tests/assesstype/assesstype_test.php new file mode 100644 index 0000000..241fd17 --- /dev/null +++ b/tests/assesstype/assesstype_test.php @@ -0,0 +1,338 @@ +. + +namespace local_sitsgradepush; + +use local_sitsgradepush\assessment\assessmentfactory; + +defined('MOODLE_INTERNAL') || die(); +global $CFG; +require_once($CFG->dirroot . '/local/sitsgradepush/tests/fixtures/tests_data_provider.php'); + +/** + * Tests for the assesstype 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 assesstype_test extends \advanced_testcase { + + /** @var \stdClass Test course */ + private \stdClass $course; + + /** @var \stdClass Test grade item */ + private \stdClass $gradeitem; + + /** @var \stdClass Test grade category */ + private \stdClass $gradecategory; + + /** @var \stdClass Test assignment */ + private \stdClass $assign; + + /** @var \stdClass Test quiz */ + private \stdClass $quiz; + + /** + * Set up the test. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + // Skip the test if the plugin is not installed. + if (!assesstype::is_assess_type_installed()) { + $this->markTestSkipped('local_assess_type plugin is not installed.'); + } + + // Set admin user. + $this->setAdminUser(); + $this->setup_environment(); + $this->resetAfterTest(); + } + + /** + * Test items under a mapped grade category are marked summative and locked. + * + * @covers \local_sitsgradepush\assesstype::update_assess_type + * @covers \local_sitsgradepush\assesstype::is_assess_type_installed + * @covers \local_sitsgradepush\assesstype::update_assess_type_items_under_gradecategory + * @return void + * @throws \dml_exception + */ + public function test_items_under_mapped_grade_category_marked_summative(): void { + global $DB; + + $gradeitems = \grade_item::fetch_all(['categoryid' => $this->gradecategory->id]); + $this->assertCount(3, $gradeitems); + + // Check category's grade item is marked as summative and locked. + $category = \grade_category::fetch(['id' => $this->gradecategory->id]); + $this->assertTrue( + $DB->record_exists('local_assess_type', ['gradeitemid' => $category->load_grade_item()->id, 'type' => 1, 'locked' => 1]) + ); + + // Check manual grade item is marked as summative and locked. + $this->assertTrue( + $DB->record_exists('local_assess_type', ['gradeitemid' => $this->gradeitem->id, 'type' => 1, 'locked' => 1]) + ); + // Check assignment is marked as summative and locked. + $this->assertTrue( + $DB->record_exists('local_assess_type', ['cmid' => $this->assign->cmid, 'type' => 1, 'locked' => 1]) + ); + // Check quiz is marked as summative and locked. + $this->assertTrue( + $DB->record_exists('local_assess_type', ['cmid' => $this->quiz->cmid, 'type' => 1, 'locked' => 1]) + ); + } + + /** + * Test a grade category mapping is removed. + * + * @covers \local_sitsgradepush\manager::remove_mapping + * @covers \local_sitsgradepush\assesstype::update_assess_type + * @covers \local_sitsgradepush\assesstype::update_assess_type_items_under_gradecategory + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_remove_grade_category_mapping(): void { + global $DB; + + // Get the grade category mapping. + $mapping = $DB->get_record( + manager::TABLE_ASSESSMENT_MAPPING, + [ + 'courseid' => $this->course->id, + 'sourcetype' => assessmentfactory::SOURCETYPE_GRADE_CATEGORY, + 'sourceid' => $this->gradecategory->id, + ] + ); + + // Remove the grade category mapping. + manager::get_manager()->remove_mapping($this->course->id, $mapping->id); + + // Check category's grade item is unlocked. + $category = \grade_category::fetch(['id' => $this->gradecategory->id]); + $this->assertTrue( + $DB->record_exists('local_assess_type', ['gradeitemid' => $category->load_grade_item()->id, 'type' => 1, 'locked' => 0]) + ); + + // Check manual grade item is unlocked. + $this->assertTrue( + $DB->record_exists('local_assess_type', ['gradeitemid' => $this->gradeitem->id, 'type' => 1, 'locked' => 0]) + ); + + // Check assignment is unlocked. + $this->assertTrue( + $DB->record_exists('local_assess_type', ['cmid' => $this->assign->cmid, 'type' => 1, 'locked' => 0]) + ); + + // Check quiz is unlocked. + $this->assertTrue( + $DB->record_exists('local_assess_type', ['cmid' => $this->quiz->cmid, 'type' => 1, 'locked' => 0]) + ); + } + + /** + * Test grade item updated. + * + * @covers \local_sitsgradepush\assesstype::grade_item_updated + * @covers \local_sitsgradepush\assesstype::update_assess_type_items_under_gradecategory + * @return void + * @throws \dml_exception + */ + public function test_grade_item_updated(): void { + global $DB; + + // Get the course grade category. + $coursegradecategory = \grade_category::fetch(['courseid' => $this->course->id, 'parent' => null, 'depth' => 1]); + + // Move the manual grade item out of the mapped category. + $gradeitem = \grade_item::fetch(['id' => $this->gradeitem->id]); + $gradeitem->categoryid = $coursegradecategory->id; + $gradeitem->update(); + + // Check manual grade item is unlocked. + $this->assertTrue( + $DB->record_exists('local_assess_type', ['gradeitemid' => $this->gradeitem->id, 'type' => 1, 'locked' => 0]) + ); + + // Move the assignment out of the mapped category. + $gradeitem = \grade_item::fetch(['itemtype' => 'mod', 'iteminstance' => $this->assign->id, 'itemmodule' => 'assign']); + $gradeitem->categoryid = $coursegradecategory->id; + $gradeitem->update(); + + // Check assignment is unlocked. + $this->assertTrue( + $DB->record_exists('local_assess_type', ['cmid' => $this->assign->cmid, 'type' => 1, 'locked' => 0]) + ); + + // Move the quiz out of the mapped category. + $gradeitem = \grade_item::fetch(['itemtype' => 'mod', 'iteminstance' => $this->quiz->id, 'itemmodule' => 'quiz']); + $gradeitem->categoryid = $coursegradecategory->id; + $gradeitem->update(); + + // Check quiz is unlocked. + $this->assertTrue( + $DB->record_exists('local_assess_type', ['cmid' => $this->quiz->cmid, 'type' => 1, 'locked' => 0]) + ); + } + + /** + * Test workshop grade items. + * + * @covers \local_sitsgradepush\assesstype::grade_item_updated + * @covers \local_sitsgradepush\assesstype::update_assess_type_items_under_gradecategory + * @covers \local_sitsgradepush\assesstype::should_unlock_workshop + * @return void + * @throws \dml_exception + */ + public function test_workshop_grade_items(): void { + global $DB; + + // Create a workshop. + $workshop = $this->getDataGenerator()->create_module('workshop', ['course' => $this->course->id]); + + // Get the first grade item of the workshop. + $gradeitem1 = \grade_item::fetch(['itemmodule' => 'workshop', 'iteminstance' => $workshop->id, 'itemnumber' => 0]); + + // Move the workshop grade item to the mapped category. + $gradeitem1->categoryid = $this->gradecategory->id; + $gradeitem1->update(); + + // Check the workshop is marked as summative and locked. + $this->assertTrue( + $DB->record_exists('local_assess_type', ['cmid' => $workshop->cmid, 'type' => 1, 'locked' => 1]) + ); + + // Get the second grade item of the workshop. + $gradeitem2 = \grade_item::fetch(['itemmodule' => 'workshop', 'iteminstance' => $workshop->id, 'itemnumber' => 1]); + + // Move the workshop grade item to the mapped category. + $gradeitem2->categoryid = $this->gradecategory->id; + $gradeitem2->update(); + + // Now remove the first grade item from the mapped category. + $coursecategory = \grade_category::fetch(['courseid' => $this->course->id, 'parent' => null, 'depth' => 1]); + $gradeitem1->categoryid = $coursecategory->id; + $gradeitem1->update(); + + // Check the workshop is still marked as summative and locked. + $this->assertTrue( + $DB->record_exists('local_assess_type', ['cmid' => $workshop->cmid, 'type' => 1, 'locked' => 1]) + ); + + // Now remove the second grade item from the mapped category. + $gradeitem2->categoryid = $coursecategory->id; + $gradeitem2->update(); + + // Check the workshop is now unlocked. + $this->assertTrue( + $DB->record_exists('local_assess_type', ['cmid' => $workshop->cmid, 'type' => 1, 'locked' => 0]) + ); + } + + /** + * Set up the testing environment. + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + */ + protected function setup_environment() { + global $CFG, $DB; + require_once($CFG->libdir . '/gradelib.php'); + + // Set up the testing environment. + tests_data_provider::import_sitsgradepush_grade_components(); + + // Set block_lifecycle 'late_summer_assessment_end_date'. + set_config('late_summer_assessment_end_' . date('Y'), date('Y-m-d', strtotime('+2 month')), 'block_lifecycle'); + + // Create a custom category and custom field. + $this->getDataGenerator()->create_custom_field_category(['name' => 'CLC']); + $this->getDataGenerator()->create_custom_field(['category' => 'CLC', 'shortname' => 'course_year']); + + // Create test course. + $this->course = $this->getDataGenerator()->create_course( + ['shortname' => 'C1', 'customfields' => [ + ['shortname' => 'course_year', 'value' => date('Y')], + ]]); + $this->gradecategory = + $this->getDataGenerator()->create_grade_category(['courseid' => $this->course->id]); // Create grade category. + $this->gradeitem = $this->create_grade_item(); // Create grade item. + $this->assign = $this->getDataGenerator()->create_module('assign', ['course' => $this->course->id]); // Create assignment. + $this->quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $this->course->id]); // Create quiz. + + // Add grade item and activities to grade category. + $gradeitem = \grade_item::fetch(['id' => $this->gradeitem->id]); + $gradeitem->categoryid = $this->gradecategory->id; + $gradeitem->update(); + + // Add assignment to grade category. + $gradeitem = \grade_item::fetch(['itemtype' => 'mod', 'iteminstance' => $this->assign->id, 'itemmodule' => 'assign']); + $gradeitem->categoryid = $this->gradecategory->id; + $gradeitem->update(); + + // Add quiz to grade category. + $gradeitem = \grade_item::fetch(['itemtype' => 'mod', 'iteminstance' => $this->quiz->id, 'itemmodule' => 'quiz']); + $gradeitem->categoryid = $this->gradecategory->id; + $gradeitem->update(); + + // Add assessment mapping. + $mab = $DB->get_record(manager::TABLE_COMPONENT_GRADE, ['mapcode' => 'LAWS0024A6UF', 'mabseq' => '001']); + $mapping = new \stdClass(); + $mapping->courseid = $this->course->id; + $mapping->sourcetype = assessmentfactory::SOURCETYPE_GRADE_CATEGORY; + $mapping->sourceid = $this->gradecategory->id; + $mapping->componentgradeid = $mab->id; + $mapping->reassessment = 0; + + manager::get_manager()->save_assessment_mapping($mapping); + } + + /** + * Create a grade item. + * + * @return \stdClass + */ + private function create_grade_item(): \stdClass { + // Create grade item. + return $this->getDataGenerator()->create_grade_item([ + 'courseid' => $this->course->id, + 'itemname' => 'Grade item', + 'gradetype' => GRADE_TYPE_VALUE, + 'grademax' => 100, + 'grademin' => 0, + 'scaleid' => 0, + 'multfactor' => 1.0, + 'plusfactor' => 0.0, + 'aggregationcoef' => 0.0, + 'aggregationcoef2' => 0.0, + 'sortorder' => 1, + 'hidden' => 0, + 'locked' => 0, + 'locktime' => 0, + 'needsupdate' => 0, + 'weightoverride' => 0, + 'timecreated' => time(), + 'timemodified' => time(), + ]); + } +} diff --git a/version.php b/version.php index 99d9ac5..8a70495 100644 --- a/version.php +++ b/version.php @@ -27,7 +27,7 @@ $plugin->component = 'local_sitsgradepush'; $plugin->release = '0.1.0'; -$plugin->version = 2024101000; +$plugin->version = 2024101001; $plugin->requires = 2023100900; $plugin->maturity = MATURITY_ALPHA; $plugin->dependencies = [