diff --git a/README.md b/README.md index f69a702..8a8555b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,14 @@ An activity module for managing custom defined content types which are 'first class' concepts in Moodle. This is to enable course author to think in proper concepts that matter to them and not worry about the rendering of each content type which will be defined centrally. +## Branches ## +The following maps the plugin version to use depending on your Moodle version. + +| Moodle version | Branch | +|-------------------| ------------------| +| Moodle 3.9 to 4.0 | MOODLE_39_STABLE | +| Moodle 4.1 | MOODLE_401_STABLE | + ## Installation Step 1: Install the activity module @@ -13,7 +21,7 @@ Step 1: Install the activity module Using git submodule: ``` -git submodule add git@github.com:catalyst/moodle-mod_cms.git mod/cms +git submodule add -b MOODLE_401_STABLE git@github.com:catalyst/moodle-mod_cms.git mod/cms ``` Or you can download as a zip from github diff --git a/classes/search/cmsfield.php b/classes/search/cmsfield.php new file mode 100644 index 0000000..e7e571d --- /dev/null +++ b/classes/search/cmsfield.php @@ -0,0 +1,256 @@ +. + +namespace mod_cms\search; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/mod/cms/lib.php'); + +/** + * Define search area. + * + * @package mod_cms + * @author Tomo Tsuyuki + * @copyright 2024 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cmsfield extends \core_search\base_mod { + + /** + * @var array Internal quick static cache. + */ + protected $cmsdata = []; + + /** + * @var array Internal quick static cache. + */ + protected $defaultvalues = null; + + /** + * Returns recordset containing required data for indexing cmsfield records. + * + * @param int $modifiedfrom timestamp + * @param \context|null $context Optional context to restrict scope of returned results + * @return \moodle_recordset|null Recordset (or null if no results) + */ + public function get_document_recordset($modifiedfrom = 0, \context $context = null) { + global $DB; + + list ($contextjoin, $contextparams) = $this->get_context_restriction_sql( + $context, 'cms', 'mc'); + if ($contextjoin === null) { + return null; + } + + // Search area is from customfield_data, but if the record is missing from activity, use default value. + $sqlgroupconcat = $DB->sql_group_concat("mcd.value", ', ', 'mcf.sortorder'); + $sql = "SELECT ccms.id, ccms.course AS courseid, ccms.typeid, cmcf.name AS fieldname, cmcf.type, + cdata.dataid dataid, cdata.value AS value, cdata.valueformat AS valueformat, + cdata.timecreated AS timecreated, cdata.timemodified AS timemodified + FROM {cms} ccms + JOIN ( + SELECT mc.id, MAX(mcf.id) AS fieldid, + MAX(mcd.id) dataid, {$sqlgroupconcat} AS value, MAX(mcd.valueformat) AS valueformat, + MAX(mcd.timecreated) AS timecreated, MAX(mcd.timemodified) AS timemodified + FROM {cms} mc + JOIN {customfield_data} mcd ON mc.id = mcd.instanceid + JOIN {customfield_field} mcf ON mcf.id = mcd.fieldid + JOIN {customfield_category} mcc ON mcf.categoryid = mcc.id + $contextjoin + WHERE mcd.timemodified >= ? AND mcc.component = 'mod_cms' AND mcc.area = 'cmsfield' + AND mcf.type IN ('textarea', 'text') + GROUP BY mc.id + ) cdata ON ccms.id = cdata.id + JOIN {customfield_field} cmcf ON cmcf.id = cdata.fieldid + UNION + SELECT mc.id, mc.course AS courseid, mc.typeid, null AS fieldname, null AS type, + null AS dataid, null AS value, null AS valueformat, + mc.timecreated timecreated, mc.timemodified timemodified + FROM {cms} mc + LEFT JOIN {customfield_data} mcd ON mc.id = mcd.instanceid + WHERE mcd.id IS NULL AND mc.timecreated >= ? + ORDER BY timemodified ASC"; + return $DB->get_recordset_sql($sql, array_merge($contextparams, [$modifiedfrom, $modifiedfrom])); + } + + /** + * Returns the document associated with this data id. + * + * @param stdClass $record + * @param array $options + * @return \core_search\document + */ + public function get_document($record, $options = []) { + global $DB; + try { + $cm = $this->get_cm('cms', $record->id, $record->courseid); + $context = \context_module::instance($cm->id); + } catch (\dml_missing_record_exception $ex) { + // Notify it as we run here as admin, we should see everything. + debugging('Error retrieving ' . $this->areaid . ' ' . $record->id . ' document, not all required data is available: ' . + $ex->getMessage(), DEBUG_DEVELOPER); + return false; + } catch (\dml_exception $ex) { + // Notify it as we run here as admin, we should see everything. + debugging('Error retrieving ' . $this->areaid . ' ' . $record->id . ' document: ' . $ex->getMessage(), DEBUG_DEVELOPER); + return false; + } + + $defaultvalues = $this->get_default_values(); + + // Check if it's default value or not. + if (empty($record->dataid)) { + $title = $defaultvalues[$record->typeid]->fieldname ?? ''; + $value = $defaultvalues[$record->typeid]->value ?? ''; + if (isset($defaultvalues[$record->typeid]->valueformat)) { + $valueformat = $defaultvalues[$record->typeid]->valueformat; + } else { + if ($record->type == 'textarea') { + $valueformat = FORMAT_HTML; + } else { + $valueformat = FORMAT_PLAIN; + } + } + } else { + $title = $record->fieldname; + $value = $record->value; + $valueformat = $record->valueformat; + } + + // Prepare associative array with data from DB. + $doc = \core_search\document_factory::instance($record->id, $this->componentname, $this->areaname); + $doc->set('title', content_to_text($title, false)); + $doc->set('content', content_to_text($value, $valueformat)); + $doc->set('contextid', $context->id); + $doc->set('courseid', $record->courseid); + $doc->set('owneruserid', \core_search\manager::NO_OWNER_ID); + $doc->set('modified', $record->timemodified); + + // Check if this document should be considered new. + if (isset($options['lastindexedtime']) && ($options['lastindexedtime'] < $record->timecreated)) { + // If the document was created after the last index time, it must be new. + $doc->set_is_new(true); + } + + return $doc; + } + + /** + * Get default value for cms custom field. + * + * @return array + */ + protected function get_default_values() { + global $DB; + if (is_null($this->defaultvalues)) { + $defaultvalues = []; + $sql = "SELECT mcf.id fieldid, mct.id typeid, mcf.configdata, mcf.name fieldname + FROM {cms_types} mct + JOIN {customfield_category} mcc ON mcc.itemid = mct.id + JOIN {customfield_field} mcf ON mcf.categoryid = mcc.id + WHERE mcc.component = 'mod_cms' AND mcc.area = 'cmsfield' AND mcf.type IN ('textarea', 'text') + ORDER BY mct.id, mcf.sortorder"; + $cmstypes = $DB->get_records_sql($sql); + foreach ($cmstypes as $cmstype) { + if (empty($defaultvalues[$cmstype->typeid])) { + $data = new \stdClass(); + $configdata = json_decode($cmstype->configdata); + $data->value = $configdata->defaultvalue ?? 'Default value'; + $data->valueformat = $configdata->defaultvalueformat ?? 0; + $data->fieldname = $cmstype->fieldname; + } else { + $data = $defaultvalues[$cmstype->typeid]; + $configdata = json_decode($cmstype->configdata); + $data->value .= ', ' . $configdata->defaultvalue; + } + $defaultvalues[$cmstype->typeid] = $data; + } + $this->defaultvalues = $defaultvalues; + } + return $this->defaultvalues; + } + + /** + * Whether the user can access the document or not. + * + * @param int $id data id + * @return bool + */ + public function check_access($id) { + try { + $data = $this->get_data($id); + $cminfo = $this->get_cm('cms', $data->id, $data->courseid); + $context = \context_module::instance($cminfo->id); + } catch (\dml_missing_record_exception $ex) { + return \core_search\manager::ACCESS_DELETED; + } catch (\dml_exception $ex) { + return \core_search\manager::ACCESS_DENIED; + } + + // Recheck uservisible although it should have already been checked in core_search. + if ($cminfo->uservisible === false) { + return \core_search\manager::ACCESS_DENIED; + } + + if (!has_capability('mod/cms:view', $context)) { + return \core_search\manager::ACCESS_DENIED; + } + + return \core_search\manager::ACCESS_GRANTED; + } + + /** + * Link to the cms. + * + * @param \core_search\document $doc + * @return \moodle_url + */ + public function get_doc_url(\core_search\document $doc) { + $contextmodule = \context::instance_by_id($doc->get('contextid')); + $cm = get_coursemodule_from_id('cms', $contextmodule->instanceid, $doc->get('courseid'), true); + return new \moodle_url('/course/view.php', ['id' => $doc->get('courseid'), 'section' => $cm->sectionnum]); + } + + /** + * Link to the cms. + * + * @param \core_search\document $doc + * @return \moodle_url + */ + public function get_context_url(\core_search\document $doc) { + $contextmodule = \context::instance_by_id($doc->get('contextid')); + return new \moodle_url('/mod/cms/view.php', ['id' => $contextmodule->instanceid]); + } + + /** + * Returns the specified data from its internal cache. + * + * @throws \dml_missing_record_exception + * @param int $id + * @return stdClass + */ + protected function get_data($id) { + global $DB; + if (empty($this->cmsdata[$id])) { + $sql = "SELECT mc.id, mc.course AS courseid + FROM {cms} mc + WHERE mc.id = :id"; + $this->cmsdata[$id] = $DB->get_record_sql($sql, ['id' => $id], MUST_EXIST); + } + return $this->cmsdata[$id]; + } +} diff --git a/lang/en/cms.php b/lang/en/cms.php index f69fea6..9e88fda 100644 --- a/lang/en/cms.php +++ b/lang/en/cms.php @@ -103,6 +103,9 @@ $string['error:no_instance_hash'] = 'Module {$a} has no instance hash.'; $string['error:no_config_hash'] = 'Module {$a} has no config hash.'; +// Search strings. +$string['search:cmsfield'] = 'CMS'; + // Site datasource strings. $string['site:displayname'] = 'Site Info'; diff --git a/tests/generator/lib.php b/tests/generator/lib.php index 58e9bfc..d3d4601 100644 --- a/tests/generator/lib.php +++ b/tests/generator/lib.php @@ -15,6 +15,7 @@ // along with Moodle. If not, see . use mod_cms\local\model\cms_types; +use mod_cms\customfield\cmsfield_handler; use core_customfield\category_controller; use core_customfield\field_controller; @@ -47,6 +48,25 @@ public function create_instance($record = null, array $options = null) { return parent::create_instance($record, (array) $options); } + /** + * Create new cms module instance with custom data + * + * @param array|stdClass $record + * @param null|array $options + * @return stdClass + */ + public function create_instance_with_data($record = null, ?array $options = null): object { + $record = (object)$record; + $cms = parent::create_instance($record, $options); + + // Save customfield. + $handler = cmsfield_handler::create($cms->typeid); + $record->id = $cms->id; + $handler->instance_form_save($record); + + return $cms; + } + /** * Get generator for custom fields. * @return core_customfield_generator diff --git a/tests/search/search_test.php b/tests/search/search_test.php new file mode 100644 index 0000000..c628481 --- /dev/null +++ b/tests/search/search_test.php @@ -0,0 +1,362 @@ +. + +/** + * CMS search unit tests. + * + * @package mod_cms + * @category test + * @author Tomo Tsuyuki + * @copyright 2024 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_cms\search; + +use mod_cms\customfield\cmsfield_handler; +use mod_cms\local\model\cms_types; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php'); + +/** + * Test class for cmsfield search. + * + * @package mod_cms + * @category test + * @author Tomo Tsuyuki + * @copyright 2024 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \mod_cms\search\cmsfield + */ +class search_test extends \advanced_testcase { + + /** + * @var string Area id + */ + protected $cmsareaid = null; + + /** + * @var cms_types CMS type object + */ + protected $cmstype = null; + + /** + * @var \core_customfield\category_controller Custom field category object + */ + protected $fieldcategory = null; + + /** + * @var \core_customfield\field_controller Custom field object + */ + protected $field = null; + + /** + * Set up. + */ + public function setUp(): void { + $this->resetAfterTest(); + set_config('enableglobalsearch', true); + + $this->cmsareaid = \core_search\manager::generate_areaid('mod_cms', 'cmsfield'); + + // Set \core_search::instance to the mock_search_engine as we don't require the search engine to be working to test this. + $search = \testable_core_search::instance(); + + // Name for cms activity is using from "title_mustache". + $cmstype = new cms_types(); + $cmstype->set('name', 'Overview') + ->set('idnumber', 'overview') + ->set('title_mustache', 'Overview'); + $cmstype->save(); + $fieldcategory = self::getDataGenerator()->create_custom_field_category([ + 'name' => 'Other fields', + 'component' => 'mod_cms', + 'area' => 'cmsfield', + 'itemid' => $cmstype->get('id'), + ]); + $field = self::getDataGenerator()->create_custom_field([ + 'name' => 'Overview', + 'shortname' => 'overview', + 'type' => 'text', + 'categoryid' => $fieldcategory->get('id'), + 'configdata' => json_encode(['defaultvalue' => 'Default Text Overview']), + ]); + $this->cmstype = $cmstype; + $this->fieldcategory = $fieldcategory; + $this->field = $field; + } + + /** + * Test search enabled. + * + * @return void + * @covers \core_search\manager::get_search_area + */ + public function test_search_enabled(): void { + $searcharea = \core_search\manager::get_search_area($this->cmsareaid); + list($componentname, $varname) = $searcharea->get_config_var_name(); + + // Enabled by default once global search is enabled. + $this->assertTrue($searcharea->is_enabled()); + + set_config($varname . '_enabled', 0, $componentname); + $this->assertFalse($searcharea->is_enabled()); + + set_config($varname . '_enabled', 1, $componentname); + $this->assertTrue($searcharea->is_enabled()); + } + + /** + * Indexing mod cms contents. + * + * @return void + * @covers ::get_document_recordset + * @covers ::get_document + */ + public function test_get_document_recordset(): void { + global $DB; + + // Returns the instance as long as the area is supported. + $searcharea = \core_search\manager::get_search_area($this->cmsareaid); + $this->assertInstanceOf('\mod_cms\search\cmsfield', $searcharea); + + $course = self::getDataGenerator()->create_course(); + + // The name of cms activity is from cms_type, so we do not set when creating the activity. + $generator = self::getDataGenerator()->get_plugin_generator('mod_cms'); + $record = new \stdClass(); + $record->course = $course->id; + $record->customfield_overview = 'Test overview text 1'; + $record->typeid = $this->cmstype->get('id'); + $cms1 = $generator->create_instance_with_data($record); + + $record = new \stdClass(); + $record->course = $course->id; + $record->customfield_overview = 'Test overview text 2'; + $record->typeid = $this->cmstype->get('id'); + $cms2 = $generator->create_instance_with_data($record); + + // All records. + $recordset = $searcharea->get_document_recordset(); + $this->assertTrue($recordset->valid()); + $this->assertEquals(2, iterator_count($recordset)); + $recordset->close(); + + // Search again by current time + 2 sec. + $recordset = $searcharea->get_document_recordset(time() + 2); + // No new records. + $this->assertFalse($recordset->valid()); + $recordset->close(); + + // Wait 1 sec to have new search string. + sleep(1); + $time = time(); + $record = new \stdClass(); + $record->course = $course->id; + $record->customfield_overview = 'Test overview text 3'; + $record->typeid = $this->cmstype->get('id'); + $cms3 = $generator->create_instance_with_data($record); + $context = \context_module::instance($cms3->cmid); + + // Return only new search. + $recordset = $searcharea->get_document_recordset($time); + $count = 0; + foreach ($recordset as $record) { + $this->assertInstanceOf('stdClass', $record); + $data = $DB->get_record('customfield_data', ['id' => $record->dataid]); + $doc = $searcharea->get_document($record); + $this->assertInstanceOf('\core_search\document', $doc); + $this->assertEquals('mod_cms-cmsfield-' . $record->id, $doc->get('id')); + $this->assertEquals($record->id, $doc->get('itemid')); + $this->assertEquals($course->id, $doc->get('courseid')); + $this->assertEquals($context->id, $doc->get('contextid')); + $this->assertEquals($this->field->get('name'), $doc->get('title')); + $this->assertEquals($data->value, $doc->get('content')); + + // Static caches are working. + $dbreads = $DB->perf_get_reads(); + $doc = $searcharea->get_document($record); + $this->assertEquals($dbreads, $DB->perf_get_reads()); + $this->assertInstanceOf('\core_search\document', $doc); + $count++; + } + $this->assertEquals(1, $count); + $recordset->close(); + + // Update existing data. + $cms1->customfield_overview = 'Update test 1'; + $handler = cmsfield_handler::create($cms1->typeid); + $handler->instance_form_save($cms1); + // Return 2 records. + $recordset = $searcharea->get_document_recordset($time); + $this->assertTrue($recordset->valid()); + $this->assertEquals(2, iterator_count($recordset)); + $recordset->close(); + } + + /** + * Test default value from cms content type + * + * @return void + * @covers ::get_document_recordset + * @covers ::get_document + */ + public function test_default_content(): void { + $searcharea = \core_search\manager::get_search_area($this->cmsareaid); + $this->assertInstanceOf('\mod_cms\search\cmsfield', $searcharea); + + $course = self::getDataGenerator()->create_course(); + + // Create cms activity without customfield. + $generator = self::getDataGenerator()->get_plugin_generator('mod_cms'); + $record = new \stdClass(); + $record->course = $course->id; + $record->typeid = $this->cmstype->get('id'); + $cms1 = $generator->create_instance_with_data($record); + + $recordset = $searcharea->get_document_recordset(); + $count = 0; + foreach ($recordset as $record) { + $this->assertInstanceOf('stdClass', $record); + $this->assertEmpty($record->dataid); + $doc = $searcharea->get_document($record); + $this->assertInstanceOf('\core_search\document', $doc); + // Confirm the content is from defaultvalue from cms fieldtype. + $this->assertEquals('Default Text Overview', $doc->get('content')); + $count++; + } + $this->assertEquals(1, $count); + $recordset->close(); + + // Add custom data for the cms activity. + $cms1->customfield_overview = 'Update test 1'; + $handler = cmsfield_handler::create($cms1->typeid); + $handler->instance_form_save($cms1); + $recordset = $searcharea->get_document_recordset(); + $count = 0; + foreach ($recordset as $record) { + $this->assertInstanceOf('stdClass', $record); + $doc = $searcharea->get_document($record); + $this->assertEquals('Update test 1', $doc->get('content')); + $count++; + } + $this->assertEquals(1, $count); + $recordset->close(); + } + + /** + * Test multiple contents in one cms activity + * + * @return void + * @covers ::get_document_recordset + * @covers ::get_document + */ + public function test_multiple_contents(): void { + global $DB; + + // Returns the instance as long as the area is supported. + $searcharea = \core_search\manager::get_search_area($this->cmsareaid); + $this->assertInstanceOf('\mod_cms\search\cmsfield', $searcharea); + + $course = self::getDataGenerator()->create_course(); + $field = self::getDataGenerator()->create_custom_field([ + 'name' => 'Details', + 'shortname' => 'details', + 'type' => 'text', + 'categoryid' => $this->fieldcategory->get('id'), + 'configdata' => json_encode(['defaultvalue' => 'Default Text Details']), + ]); + + $generator = self::getDataGenerator()->get_plugin_generator('mod_cms'); + $record = new \stdClass(); + $record->course = $course->id; + $record->typeid = $this->cmstype->get('id'); + $cms1 = $generator->create_instance_with_data($record); + + $recordset = $searcharea->get_document_recordset(); + $count = 0; + foreach ($recordset as $record) { + $this->assertInstanceOf('stdClass', $record); + $this->assertEmpty($record->dataid); + $doc = $searcharea->get_document($record); + $this->assertInstanceOf('\core_search\document', $doc); + $this->assertStringContainsString('Default Text Overview', $doc->get('content')); + $this->assertStringContainsString('Default Text Details', $doc->get('content')); + $count++; + } + $this->assertEquals(1, $count); + $recordset->close(); + + // Add data for the cms activity. + $cms1->customfield_overview = 'Overview test 1'; + $cms1->customfield_details = 'Details test 1'; + $handler = cmsfield_handler::create($cms1->typeid); + $handler->instance_form_save($cms1); + $recordset = $searcharea->get_document_recordset(); + $count = 0; + foreach ($recordset as $record) { + $this->assertInstanceOf('stdClass', $record); + $doc = $searcharea->get_document($record); + // Both strings are contained in the cms activity. + $this->assertStringContainsString('Overview test 1', $doc->get('content')); + $this->assertStringContainsString('Details test 1', $doc->get('content')); + $count++; + } + $this->assertEquals(1, $count); + $recordset->close(); + } + + /** + * Test check_access. + * + * @return void + * @covers ::check_access + */ + public function test_check_access(): void { + global $DB; + + // Returns the instance as long as the area is supported. + $searcharea = \core_search\manager::get_search_area($this->cmsareaid); + + $user1 = self::getDataGenerator()->create_user(); + $user2 = self::getDataGenerator()->create_user(); + $course = self::getDataGenerator()->create_course(); + + $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student'); + + $generator = self::getDataGenerator()->get_plugin_generator('mod_cms'); + $record = new \stdClass(); + $record->course = $course->id; + $record->customfield_overview = 'Test overview text 1'; + $record->typeid = $this->cmstype->get('id'); + $cms = $generator->create_instance_with_data($record); + + $records = $DB->get_records('customfield_data', ['fieldid' => $this->field->get('id')]); + $this->assertCount(1, $records); + + $this->setAdminUser(); + $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($cms->id)); + + $this->setUser($user1); + $this->assertEquals(\core_search\manager::ACCESS_GRANTED, $searcharea->check_access($cms->id)); + + $this->setUser($user2); + $this->assertEquals(\core_search\manager::ACCESS_DENIED, $searcharea->check_access($cms->id)); + } +} diff --git a/version.php b/version.php index 17c6f1b..a79caa6 100644 --- a/version.php +++ b/version.php @@ -25,9 +25,9 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2023120100; -$plugin->requires = 2020061500; // Moodle 3.9.0 and above. -$plugin->supported = [39, 401]; // Moodle 3.9 to 4.1 inclusive. +$plugin->version = 2024090300; +$plugin->requires = 2022112800; // Moodle 4.1 and above. +$plugin->supported = [401, 401]; // Moodle 4.1. $plugin->component = 'mod_cms'; $plugin->maturity = MATURITY_STABLE; $plugin->release = 2023051800;