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 = [