diff --git a/customfield/field/number/classes/observer.php b/customfield/field/number/classes/observer.php new file mode 100644 index 00000000000..32438b27222 --- /dev/null +++ b/customfield/field/number/classes/observer.php @@ -0,0 +1,112 @@ +. + +namespace customfield_number; + +use customfield_number\local\numberproviders\nofactivities; +use customfield_number\task\recalculate; + +/** + * Event observers for customfield_number + * + * @package customfield_number + * @copyright Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class observer { + + /** + * When a 'number' custom field was created, schedule recalculation for the field data + * + * @param \core_customfield\event\field_created $event + */ + public static function field_created(\core_customfield\event\field_created $event): void { + $field = $event->get_record_snapshot('customfield_field', $event->objectid); + if ($field->type === 'number') { + recalculate::schedule_for_field($event->objectid); + } + } + + /** + * When a 'number' custom field was updated, schedule recalculation for the field data + * + * @param \core_customfield\event\field_updated $event + */ + public static function field_updated(\core_customfield\event\field_updated $event): void { + $field = $event->get_record_snapshot('customfield_field', $event->objectid); + if ($field->type === 'number') { + recalculate::schedule_for_field($event->objectid); + } + } + + /** + * When a course module was created, schedule recalculation for all 'nofactivities' custom fields + * + * @param \core\event\course_module_created $event + */ + public static function course_module_created(\core\event\course_module_created $event): void { + if (self::has_nofactivities_fields()) { + recalculate::schedule_for_fieldtype(fieldtype: nofactivities::class, + component: 'core_course', area: 'course', instanceid: $event->courseid); + } + } + + /** + * When a course module was deleted, schedule recalculation for all 'nofactivities' custom fields + * + * @param \core\event\course_module_deleted $event + */ + public static function course_module_deleted(\core\event\course_module_deleted $event): void { + if (self::has_nofactivities_fields()) { + recalculate::schedule_for_fieldtype(fieldtype: nofactivities::class, + component: 'core_course', area: 'course', instanceid: $event->courseid); + } + } + + /** + * When a course module was updated, schedule recalculation for all 'nofactivities' custom fields + * + * Module visibility may change following an 'updated' event and it will affect the activities count + * + * @param \core\event\course_module_updated $event + */ + public static function course_module_updated(\core\event\course_module_updated $event): void { + if (self::has_nofactivities_fields()) { + recalculate::schedule_for_fieldtype(fieldtype: nofactivities::class, + component: 'core_course', area: 'course', instanceid: $event->courseid); + } + } + + /** + * Checks if a 'number' field with 'nofactivities' provider exists in the course fields + * + * This method is very fast, it only performs one DB query and only once per request + * + * @return bool + */ + protected static function has_nofactivities_fields(): bool { + $handler = \core_course\customfield\course_handler::create(); + foreach ($handler->get_categories_with_fields() as $category) { + foreach ($category->get_fields() as $field) { + if ($field->get('type') === 'number' && + $field->get_configdata_property('fieldtype') === nofactivities::class) { + return true; + } + } + } + return false; + } +} diff --git a/customfield/field/number/classes/task/recalculate.php b/customfield/field/number/classes/task/recalculate.php new file mode 100644 index 00000000000..840447d8f3f --- /dev/null +++ b/customfield/field/number/classes/task/recalculate.php @@ -0,0 +1,118 @@ +. + +namespace customfield_number\task; + +use coding_exception; +use core\task\adhoc_task; +use core_customfield\field_controller; +use customfield_number\provider_base; + +/** + * Recalculates data for the given number field with a provider + * + * @since Moodle 4.5.1 + * @package customfield_number + * @copyright Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class recalculate extends adhoc_task { + #[\Override] + public function execute() { + global $DB; + $customdata = $this->get_custom_data(); + $fieldid = clean_param($customdata->fieldid ?? null, PARAM_INT); + $fieldtype = $customdata->fieldtype ?? null; + + // Find all fields that we need to recalculate (either by 'fieldid' or 'fieldtype'). + $fields = []; + if ($fieldid) { + try { + $fields[] = field_controller::create($fieldid); + } catch (\Exception $e) { + // Could be a race condition when the field was already deleted by the time ad-hoc task runs. + return; + } + } else if ($fieldtype) { + $records = $DB->get_records('customfield_field', ['type' => 'number']); + foreach ($records as $record) { + $configdata = @json_decode($record->configdata, true); + if (($configdata['fieldtype'] ?? '') === $fieldtype) { + $fields[] = field_controller::create(0, $record); + } + } + } + + // Schedule recalculate for each field, checking component, area and the presense of provider. + $instanceid = clean_param($customdata->instanceid ?? null, PARAM_INT); + foreach ($fields as $field) { + if ($this->field_is_scheduled($field) && ($provider = provider_base::instance($field))) { + $provider->recalculate($instanceid ?: null); + } + } + } + + /** + * Helper method validating that the field should be recalculated + * + * @param \core_customfield\field_controller $field + * @return bool + */ + protected function field_is_scheduled(field_controller $field): bool { + $customdata = $this->get_custom_data(); + if (!empty($customdata->component) && $field->get_handler()->get_component() !== $customdata->component) { + return false; + } + if (!empty($customdata->area) && $field->get_handler()->get_area() !== $customdata->area) { + return false; + } + return true; + } + + /** + * Schedule recalculation for the given number custom field (and optionally, instanceid) + * + * @param int $fieldid in of the custom field + * @param int|null $instanceid if specified, only recalculates for the given instance id + * @return void + */ + public static function schedule_for_field(int $fieldid, ?int $instanceid = null) { + $task = new static(); + $task->set_custom_data(['fieldid' => $fieldid, 'instanceid' => $instanceid]); + \core\task\manager::queue_adhoc_task($task, true); + } + + /** + * Schedule recalculation for all number custom fields that use the provider (optionally with instanceid) + * + * @param string $fieldtype name of the class extending provider_base + * @param string|null $component + * @param string|null $area + * @param int|null $instanceid + * @return void + */ + public static function schedule_for_fieldtype(string $fieldtype, ?string $component = null, ?string $area = null, + ?int $instanceid = null) { + $task = new static(); + $task->set_custom_data([ + 'fieldtype' => $fieldtype, + 'component' => $component, + 'area' => $area, + 'instanceid' => $instanceid, + ]); + \core\task\manager::queue_adhoc_task($task, true); + } +} diff --git a/customfield/field/number/db/events.php b/customfield/field/number/db/events.php new file mode 100644 index 00000000000..6ebcb57585e --- /dev/null +++ b/customfield/field/number/db/events.php @@ -0,0 +1,49 @@ +. + +/** + * Event observers for Number + * + * @package customfield_number + * @category event + * @copyright Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$observers = [ + [ + 'eventname' => core_customfield\event\field_created::class, + 'callback' => 'customfield_number\observer::field_created', + ], + [ + 'eventname' => core_customfield\event\field_updated::class, + 'callback' => 'customfield_number\observer::field_updated', + ], + [ + 'eventname' => core\event\course_module_created::class, + 'callback' => 'customfield_number\observer::course_module_created', + ], + [ + 'eventname' => core\event\course_module_deleted::class, + 'callback' => 'customfield_number\observer::course_module_deleted', + ], + [ + 'eventname' => core\event\course_module_updated::class, + 'callback' => 'customfield_number\observer::course_module_updated', + ], +]; diff --git a/customfield/field/number/tests/observer_test.php b/customfield/field/number/tests/observer_test.php new file mode 100644 index 00000000000..7dac110bec9 --- /dev/null +++ b/customfield/field/number/tests/observer_test.php @@ -0,0 +1,245 @@ +. + +namespace customfield_number; + +use context_module; +use customfield_number\local\numberproviders\nofactivities; +use customfield_number\task\recalculate; + +/** + * Testing event observers + * + * @covers \customfield_number\observer + * @covers \customfield_number\task\recalculate + * @package customfield_number + * @category test + * @copyright Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class observer_test extends \advanced_testcase { + + /** + * Create a number custom field + * + * @param array $configdata + * @return \customfield_number\field_controller + */ + protected function create_number_custom_field(array $configdata): field_controller { + /** @var \core_customfield_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); + + // Create a category and field. + $category = $generator->create_category(); + $field = $generator->create_field([ + 'categoryid' => $category->get('id'), + 'type' => 'number', + 'configdata' => $configdata, + ]); + return $field; + } + + /** + * Helper function that checks if the recalculate ad-hoc task is scheduled + * + * @param bool $mustbescheduled - when false checks that the adhoc task is NOT scheduled + * @return void + */ + protected function ensure_number_adhoc_task_is_scheduled(bool $mustbescheduled): void { + $tasks = array_filter( + \core\task\manager::get_candidate_adhoc_tasks(time(), 1200, null), + fn($task) => $task->classname === '\\' . recalculate::class + ); + if ($mustbescheduled && empty($tasks)) { + $this->fail('Recalculate ad-hoc task is not scheduled.'); + } else if (!$mustbescheduled && !empty($tasks)) { + $this->fail('Recalculate ad-hoc task is scheduled when it is not expected.'); + } + } + + /** + * Test for observer for field_created event + */ + public function test_field_created(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $course1 = $this->getDataGenerator()->create_course(); + $course2 = $this->getDataGenerator()->create_course(); + $assigngenerator = $this->getDataGenerator()->get_plugin_generator('mod_assign'); + $assign1 = $assigngenerator->create_instance(['course' => $course1->id, 'visible' => 1]); + $assigngenerator->create_instance(['course' => $course1->id, 'visible' => 1]); + + // Create a number field with a provider. + $field = $this->create_number_custom_field(['fieldtype' => nofactivities::class, 'activitytypes' => ['assign', 'forum']]); + + // Execute scheduled ad-hoc tasks and it will populate the data for the course. + $this->ensure_number_adhoc_task_is_scheduled(true); + $this->run_all_adhoc_tasks(); + + $alldata = $DB->get_records_menu('customfield_data', + ['fieldid' => $field->get('id')], 'instanceid', 'instanceid, decvalue'); + $this->assertEquals([$course1->id => 2], $alldata); + + // Creating another field type does not schedule tasks. + $this->ensure_number_adhoc_task_is_scheduled(false); + $this->getDataGenerator()->get_plugin_generator('core_customfield')->create_field((object)[ + 'categoryid' => $field->get_category()->get('id'), + 'type' => 'textarea', + ]); + $this->ensure_number_adhoc_task_is_scheduled(false); + } + + /** + * Test for observer for field_updated event + */ + public function test_field_updated(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $course1 = $this->getDataGenerator()->create_course(); + $course2 = $this->getDataGenerator()->create_course(); + /** @var \mod_assign_generator $assigngenerator */ + $assigngenerator = $this->getDataGenerator()->get_plugin_generator('mod_assign'); + $assign1 = $assigngenerator->create_instance(['course' => $course1->id, 'visible' => 1]); + $assigngenerator->create_instance(['course' => $course1->id, 'visible' => 1]); + + // Create a simple number field. + $field = $this->create_number_custom_field([]); + + // There is no data for this field yet. + $this->ensure_number_adhoc_task_is_scheduled(true); + $this->run_all_adhoc_tasks(); + $this->assertEmpty($DB->get_records('customfield_data', ['fieldid' => $field->get('id')])); + + // Update this field to use nofactivities as provider. + $params = ['fieldtype' => nofactivities::class, 'activitytypes' => ['assign', 'forum']]; + \core_customfield\api::save_field_configuration($field, (object)['configdata' => $params]); + + // Now an ad-hoc task is scheduled and the data is populated. + $this->ensure_number_adhoc_task_is_scheduled(true); + $this->run_all_adhoc_tasks(); + + $alldata = $DB->get_records_menu( + 'customfield_data', + ['fieldid' => $field->get('id')], + 'instanceid', + 'instanceid, decvalue' + ); + $this->assertEquals([$course1->id => 2], $alldata); + } + + /** + * Test for observer for course_module_created, course_module_updated and course_module_deleted events + */ + public function test_course_module_events(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $course1 = $this->getDataGenerator()->create_course(); + $course2 = $this->getDataGenerator()->create_course(); + + // Create a number field with a provider. + $field = $this->create_number_custom_field(['fieldtype' => nofactivities::class, 'activitytypes' => ['assign', 'forum']]); + + // There is no data for this field yet. + $this->ensure_number_adhoc_task_is_scheduled(true); + $this->run_all_adhoc_tasks(); + $this->assertEmpty($DB->get_records('customfield_data', ['fieldid' => $field->get('id')])); + + // Create modules. + $assigngenerator = $this->getDataGenerator()->get_plugin_generator('mod_assign'); + $assign1 = $assigngenerator->create_instance(['course' => $course1->id, 'visible' => 1]); + $assign2 = $assigngenerator->create_instance(['course' => $course1->id, 'visible' => 0]); + $assigngenerator->create_instance(['course' => $course1->id, 'visible' => 1]); + + // Execute scheduled ad-hoc tasks and it will populate the data for the course. + $this->ensure_number_adhoc_task_is_scheduled(true); + $this->run_all_adhoc_tasks(); + + $alldata = $DB->get_records_menu('customfield_data', + ['fieldid' => $field->get('id')], 'instanceid', 'instanceid, decvalue'); + $this->assertEquals([$course1->id => 2], $alldata); + + // Update visibility of one module. + set_coursemodule_visible($assign2->cmid, 1); + [$course, $cm] = get_course_and_cm_from_cmid($assign2->cmid); + \core\event\course_module_updated::create_from_cm($cm, context_module::instance($assign2->cmid))->trigger(); + $this->ensure_number_adhoc_task_is_scheduled(true); + $this->run_all_adhoc_tasks(); + + $alldata = $DB->get_records_menu('customfield_data', + ['fieldid' => $field->get('id')], 'instanceid', 'instanceid, decvalue'); + $this->assertEquals([$course1->id => 3], $alldata); + + // Delete one module. + course_delete_module($assign1->cmid); + + // Execute scheduled ad-hoc tasks and it will update the data for the course. + $this->ensure_number_adhoc_task_is_scheduled(true); + $this->run_all_adhoc_tasks(); + + $alldata = $DB->get_records_menu('customfield_data', + ['fieldid' => $field->get('id')], 'instanceid', 'instanceid, decvalue'); + $this->assertEquals([$course1->id => 2], $alldata); + } + + /** + * Creating, updating and deleting modules when there are no 'nofactivities' custom fields does not schedule the ad-hoc task + * + * @return void + */ + public function test_course_module_events_without_custom_fields(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $course1 = $this->getDataGenerator()->create_course(); + $course2 = $this->getDataGenerator()->create_course(); + + // Create a number field without a provider. + $field = $this->create_number_custom_field([]); + + // Initial ad-hoc task was scheduled. + $this->ensure_number_adhoc_task_is_scheduled(true); + $this->run_all_adhoc_tasks(); + + // Create modules. + $assigngenerator = $this->getDataGenerator()->get_plugin_generator('mod_assign'); + $assign1 = $assigngenerator->create_instance(['course' => $course1->id, 'visible' => 1]); + $assign2 = $assigngenerator->create_instance(['course' => $course1->id, 'visible' => 0]); + $assigngenerator->create_instance(['course' => $course1->id, 'visible' => 1]); + + $this->ensure_number_adhoc_task_is_scheduled(false); + + // Update visibility of one module. + set_coursemodule_visible($assign2->cmid, 1); + [$course, $cm] = get_course_and_cm_from_cmid($assign2->cmid); + \core\event\course_module_updated::create_from_cm($cm, context_module::instance($assign2->cmid))->trigger(); + $this->ensure_number_adhoc_task_is_scheduled(false); + + // Delete one module. + course_delete_module($assign1->cmid); + $this->ensure_number_adhoc_task_is_scheduled(false); + } +} diff --git a/customfield/field/number/version.php b/customfield/field/number/version.php index 91c8f4c77b1..6404b38f065 100644 --- a/customfield/field/number/version.php +++ b/customfield/field/number/version.php @@ -25,6 +25,6 @@ defined('MOODLE_INTERNAL') || die; $plugin->component = 'customfield_number'; -$plugin->version = 2024100700; +$plugin->version = 2024100703; $plugin->requires = 2024100100; $plugin->maturity = MATURITY_STABLE;