diff --git a/lib/classes/plugin_manager.php b/lib/classes/plugin_manager.php index 8e829a720a2..094a5b77b85 100644 --- a/lib/classes/plugin_manager.php +++ b/lib/classes/plugin_manager.php @@ -1952,6 +1952,7 @@ class core_plugin_manager { 'qbank' => [ 'bulkmove', 'comment', + 'customfields', 'deletequestion', 'editquestion', 'exporttoxml', diff --git a/lib/classes/plugininfo/qbank.php b/lib/classes/plugininfo/qbank.php index e20fce70569..a417bd07536 100644 --- a/lib/classes/plugininfo/qbank.php +++ b/lib/classes/plugininfo/qbank.php @@ -46,6 +46,10 @@ class qbank extends base { return new \moodle_url('/admin/settings.php', ['section' => 'manageqbanks']); } + public function get_settings_section_name() { + return $this->type . '_' . $this->name; + } + public static function get_plugins($type, $typerootdir, $typeclass, $pluginman): array { global $CFG; diff --git a/lib/questionlib.php b/lib/questionlib.php index 2ed3d79dcaa..ec1853e2318 100644 --- a/lib/questionlib.php +++ b/lib/questionlib.php @@ -377,6 +377,10 @@ function question_delete_question($questionid) { // Delete all tag instances. core_tag_tag::remove_all_item_tags('core_question', 'question', $question->id); + // Delete the custom filed data for the question. + $customfieldhandler = qbank_customfields\customfield\question_handler::create(); + $customfieldhandler->delete_instance($question->id); + // Now recursively delete all child questions if ($children = $DB->get_records('question', array('parent' => $questionid), '', 'id, qtype')) { diff --git a/mod/quiz/classes/question/bank/custom_view.php b/mod/quiz/classes/question/bank/custom_view.php index 08fa9922f05..b43a9155dc4 100644 --- a/mod/quiz/classes/question/bank/custom_view.php +++ b/mod/quiz/classes/question/bank/custom_view.php @@ -25,8 +25,6 @@ namespace mod_quiz\question\bank; -use coding_exception; - /** * Subclass to customise the view of the question bank for the quiz editing screen. * @@ -58,32 +56,77 @@ class custom_view extends \core_question\local\bank\view { $this->quiz = $quiz; } - protected function wanted_columns(): array { - $quizquestionbankcolumns = [ - 'mod_quiz\\question\\bank\\add_action_column', - 'core_question\\local\\bank\\checkbox_column', - 'qbank_viewquestiontype\\question_type_column', - 'mod_quiz\\question\\bank\\question_name_text_column', - 'qbank_previewquestion\\preview_action_column', + protected function get_question_bank_plugins(): array { + $questionbankclasscolumns = []; + $corequestionbankcolumns = [ + 'add_action_column', + 'checkbox_column', + 'question_type_column', + 'question_name_text_column', + 'preview_action_column' ]; - foreach ($quizquestionbankcolumns as $fullname) { - if (!class_exists($fullname)) { - throw new coding_exception('Invalid quiz question bank column', $fullname); - } - $this->requiredcolumns[$fullname] = new $fullname($this); + if (question_get_display_preference('qbshowtext', 0, PARAM_BOOL, new \moodle_url(''))) { + $corequestionbankcolumns[] = 'question_text_row'; } - return $this->requiredcolumns; + + foreach ($corequestionbankcolumns as $fullname) { + $shortname = $fullname; + if (class_exists('mod_quiz\\question\\bank\\' . $fullname)) { + $fullname = 'mod_quiz\\question\\bank\\' . $fullname; + $questionbankclasscolumns[$shortname] = new $fullname($this); + } else if (class_exists('core_question\\local\\bank\\' . $fullname)) { + $fullname = 'core_question\\local\\bank\\' . $fullname; + $questionbankclasscolumns[$shortname] = new $fullname($this); + } else { + $questionbankclasscolumns[$shortname] = ''; + } + } + $plugins = \core_component::get_plugin_list_with_class('qbank', 'plugin_feature', 'plugin_feature.php'); + foreach ($plugins as $componentname => $plugin) { + $pluginentrypointobject = new $plugin(); + $plugincolumnobjects = $pluginentrypointobject->get_question_columns($this); + // Don't need the plugins without column objects. + if (empty($plugincolumnobjects)) { + unset($plugins[$componentname]); + continue; + } + foreach ($plugincolumnobjects as $columnobject) { + $columnname = $columnobject->get_column_name(); + foreach ($corequestionbankcolumns as $key => $corequestionbankcolumn) { + if (!\core\plugininfo\qbank::is_plugin_enabled($componentname)) { + unset($questionbankclasscolumns[$columnname]); + continue; + } + // Check if it has custom preference selector to view/hide. + if ($columnobject->has_preference() && !$columnobject->get_preference()) { + continue; + } + if ($corequestionbankcolumn === $columnname) { + $questionbankclasscolumns[$columnname] = $columnobject; + } + } + } + } + + // Mitigate the error in case of any regression. + foreach ($questionbankclasscolumns as $shortname => $questionbankclasscolumn) { + if (empty($questionbankclasscolumn)) { + unset($questionbankclasscolumns[$shortname]); + } + } + + return $questionbankclasscolumns; } protected function heading_column(): string { - return 'mod_quiz\\question\\bank\\question_name_text_column'; + return 'question_name_text_column'; } protected function default_sort(): array { return [ - 'qbank_viewquestiontype\\question_type_column' => 1, - 'mod_quiz\\question\\bank\\question_name_text_column' => 1, + 'question_type_column' => 1, + 'question_name_text_column' => 1, ]; } diff --git a/mod/quiz/tests/behat/editing_add.feature b/mod/quiz/tests/behat/editing_add.feature index 47b5254441c..04636cc0ece 100644 --- a/mod/quiz/tests/behat/editing_add.feature +++ b/mod/quiz/tests/behat/editing_add.feature @@ -186,7 +186,7 @@ Feature: Edit quiz page - adding things And I open the "last" add to quiz menu And I follow "from question bank" Then the "Add selected questions to the quiz" "button" should be disabled - And I click on "Essay 03" "checkbox" + And I click on "Select" "checkbox" in the "Essay 03" "table_row" Then the "Add selected questions to the quiz" "button" should be enabled And I click on "Add to quiz" "link" in the "Essay 03" "table_row" And I should see "Essay 03" on quiz page "1" diff --git a/mod/quiz/tests/behat/editing_add_from_question_bank.feature b/mod/quiz/tests/behat/editing_add_from_question_bank.feature index 97e7c2c767f..f1b4111c69e 100644 --- a/mod/quiz/tests/behat/editing_add_from_question_bank.feature +++ b/mod/quiz/tests/behat/editing_add_from_question_bank.feature @@ -121,7 +121,11 @@ Feature: Adding questions to a quiz from the question bank And I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" When I open the "last" add to quiz menu And I follow "from question bank" + And I click on "Sort by Question ascending" "link" + Then "question 01 name" "text" should appear before "question 02 name" "text" + And I click on "Sort by Question descending" "link" + And "question 03 name" "text" should appear before "question 01 name" "text" + And I follow "Sort by Question type ascending" + Then "question 01 name" "text" should appear before "question 03 name" "text" And I follow "Sort by Question type descending" - Then "question 03 name" "checkbox" should appear before "question 01 name" "checkbox" - And I follow "Sort by Question ascending" - And "question 01 name" "checkbox" should appear before "question 02 name" "checkbox" + Then "question 03 name" "text" should appear before "question 01 name" "text" diff --git a/question/bank/customfields/backup/moodle2/backup_qbank_customfields_plugin.class.php b/question/bank/customfields/backup/moodle2/backup_qbank_customfields_plugin.class.php new file mode 100644 index 00000000000..d224e48e9ba --- /dev/null +++ b/question/bank/customfields/backup/moodle2/backup_qbank_customfields_plugin.class.php @@ -0,0 +1,64 @@ +. + +/** + * Provides the information to backup question custom field. + * + * @package qbank_customfields + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class backup_qbank_customfields_plugin extends \backup_qbank_plugin { + + /** + * Returns the comment information to attach to question element. + * + * @return backup_plugin_element The backup plugin element + */ + protected function define_question_plugin_structure(): backup_plugin_element { + + // Define the virtual plugin element with the condition to fulfill. + $plugin = $this->get_plugin_element(); + + // Create one standard named plugin element (the visible container). + $pluginwrapper = new backup_nested_element($this->get_recommended_name()); + + // Connect the visible container ASAP. + $plugin->add_child($pluginwrapper); + + $customfields = new backup_nested_element('customfields'); + $customfield = new backup_nested_element('customfield', ['id'], ['shortname', 'type', 'value', 'valueformat']); + + $pluginwrapper->add_child($customfields); + $customfields->add_child($customfield); + + $customfield->set_source_sql("SELECT cfd.id, cff.shortname, cff.type, cfd.value, cfd.valueformat + FROM {customfield_data} cfd + JOIN {customfield_field} cff ON cff.id = cfd.fieldid + JOIN {customfield_category} cfc ON cfc.id = cff.categoryid + WHERE cfc.component = 'qbank_customfields' + AND cfc.area = 'question' + AND cfd.instanceid = ?", + [ + backup::VAR_PARENTID + ]); + + // Don't need to annotate ids nor files. + + return $plugin; + } +} diff --git a/question/bank/customfields/backup/moodle2/restore_qbank_customfields_plugin.class.php b/question/bank/customfields/backup/moodle2/restore_qbank_customfields_plugin.class.php new file mode 100644 index 00000000000..1e0de7eb207 --- /dev/null +++ b/question/bank/customfields/backup/moodle2/restore_qbank_customfields_plugin.class.php @@ -0,0 +1,63 @@ +. + +/** + * Restore plugin class that provides the necessary information needed to restore comments for questions. + * + * @package qbank_customfields + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class restore_qbank_customfields_plugin extends restore_qbank_plugin { + + /** + * Returns the paths to be handled by the plugin at question level. + * + * @return restore_path_element[] The restore path element array. + */ + protected function define_question_plugin_structure(): array { + return [new restore_path_element('customfield', $this->get_pathfor('/customfields/customfield'))]; + } + + /** + * Process the question custom field element. + * + * @param array $data The custom field data to restore. + */ + public function process_customfield(array $data) { + global $DB; + + $newquestion = $this->get_new_parentid('question'); + + if (!empty($data->contextid) && $newcontextid = $this->get_mappingid('context', $data->contextid)) { + $fieldcontextid = $newcontextid; + } else { + // Get the category, so we can then later get the context. + $categoryid = $this->get_new_parentid('question_category'); + if (empty($this->cachedcategory) || $this->cachedcategory->id != $categoryid) { + $this->cachedcategory = $DB->get_record('question_categories', ['id' => $categoryid]); + } + $fieldcontextid = $this->cachedcategory->contextid; + } + + $data['newquestion'] = $newquestion; + $data['fieldcontextid'] = $fieldcontextid; + + $customfieldhandler = qbank_customfields\customfield\question_handler::create(); + $customfieldhandler->restore_instance_data_from_backup($this->task, $data); + } +} diff --git a/question/bank/customfields/classes/custom_field_column.php b/question/bank/customfields/classes/custom_field_column.php new file mode 100644 index 00000000000..75ffb403090 --- /dev/null +++ b/question/bank/customfields/classes/custom_field_column.php @@ -0,0 +1,91 @@ +. + +namespace qbank_customfields; + +use core_question\local\bank\column_base; + +/** + * A column type for the name of the question creator. + * + * @package qbank_customfields + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class custom_field_column extends column_base { + + /** @var \core_customfield\field_controller The custom field this column is displaying. */ + protected $field; + + /** + * Constructor. + * + * @param view $qbank the question bank view we are helping to render. + * @param \core_customfield\field_controller $field The custom field this column is displaying. + */ + public function __construct(\core_question\local\bank\view $qbank, \core_customfield\field_controller $field) { + parent::__construct($qbank); + $this->field = $field; + } + + /** + * Get the internal name for this column. Used as a CSS class name, + * and to store information about the current sort. Must match PARAM_ALPHA. + * + * @return string column name. + */ + public function get_name(): string { + return 'customfield'; + } + + /** + * Get the name of this column. This must be unique. + * When using the inherited class to make many columns from one parent, + * ensure each instance returns a unique value. + * + * @return string The unique name; + */ + public function get_column_name(): string { + return 'custom_field_column\\' . $this->field->get('shortname'); + } + + /** + * Title for this column. Not used if is_sortable returns an array. + * + * @return string + */ + protected function get_title(): string { + return $this->field->get_formatted_name(); + } + + /** + * Output the contents of this column. + * + * @param object $question the row from the $question table, augmented with extra information. + * @param string $rowclasses CSS class names that should be applied to this row of output. + */ + protected function display_content($question, $rowclasses): void { + $fieldhandler = $this->field->get_handler(); + if ($fieldhandler->can_view($this->field, $question->id)) { + $fielddata = $fieldhandler->get_field_data($this->field, $question->id); + echo $fieldhandler->display_custom_field_table($fielddata); + } else { + echo ''; + } + } + +} diff --git a/question/bank/customfields/classes/customfield/question_handler.php b/question/bank/customfields/classes/customfield/question_handler.php new file mode 100644 index 00000000000..14a5ab2142c --- /dev/null +++ b/question/bank/customfields/classes/customfield/question_handler.php @@ -0,0 +1,328 @@ +. + +namespace qbank_customfields\customfield; + +use core_customfield\api; +use core_customfield\field_controller; +use core_customfield\output\field_data; + +/** + * Question handler for custom fields. + * + * @package qbank_customfields + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_handler extends \core_customfield\handler { + + /** + * @var question_handler + */ + static protected $singleton; + + /** + * @var \context + */ + protected $parentcontext; + + /** @var int Field is displayed in the question display and question preview, visible to everybody */ + const VISIBLETOALL = 2; + /** @var int Field is displayed in the question display and question preview but only for "teachers" */ + const VISIBLETOTEACHERS = 1; + /** @var int Field is not displayed in the question display and question preview */ + const NOTVISIBLE = 0; + + /** + * Creates the custom field handler and returns a singleton. + * Itemid is always zero as the custom fields are the same + * for every question across the system. + * + * @param int $itemid Always zero. + * @return \qbank_customfields\customfield\question_handler + */ + public static function create(int $itemid = 0) : \core_customfield\handler { + if (static::$singleton === null) { + self::$singleton = new static(0); + } + return self::$singleton; + } + + /** + * Run reset code after unit tests to reset the singleton usage. + */ + public static function reset_caches(): void { + if (!PHPUNIT_TEST) { + throw new \coding_exception('This feature is only intended for use in unit tests'); + } + + static::$singleton = null; + } + + /** + * The current user can configure custom fields on this component. + * + * @return bool true if the current can configure custom fields, false otherwise + */ + public function can_configure() : bool { + return has_capability('qbank/customfields:configurecustomfields', $this->get_configuration_context()); + } + + /** + * The current user can edit custom fields for the given question. + * + * @param field_controller $field + * @param int $instanceid id of the question to test edit permission + * @return bool true if the current can edit custom fields, false otherwise + */ + public function can_edit(field_controller $field, int $instanceid = 0) : bool { + if ($instanceid) { + $context = $this->get_instance_context($instanceid); + } else { + $context = $this->get_parent_context(); + } + + return (!$field->get_configdata_property('locked') || + has_capability('qbank/customfields:changelockedcustomfields', $context)); + } + + /** + * The current user can view custom fields for the given question. + * + * @param field_controller $field + * @param int $instanceid id of the question to test edit permission + * @return bool true if the current can edit custom fields, false otherwise + */ + public function can_view(field_controller $field, int $instanceid) : bool { + $visibility = $field->get_configdata_property('visibility'); + if ($visibility == self::NOTVISIBLE) { + return false; + } else if ($visibility == self::VISIBLETOTEACHERS) { + return has_capability('qbank/customfields:viewhiddencustomfields', $this->get_instance_context($instanceid)); + } else { + return true; + } + } + + /** + * Determine if the current user can view custom field in their given context. + * This determines if the user can see the field at all not just the field for + * a particular instance. + * Used primarily in showing or not the field in the question bank table. + * + * @param field_controller $field The field trying to be viewed. + * @param context $context The context the field is being displayed in. + * @return bool true if the current can edit custom fields, false otherwise. + */ + public function can_view_type(field_controller $field, \context $context) : bool { + $visibility = $field->get_configdata_property('visibility'); + if ($visibility == self::NOTVISIBLE) { + return false; + } else if ($visibility == self::VISIBLETOTEACHERS) { + return has_capability('qbank/customfields:viewhiddencustomfields', $context); + } else { + return true; + } + } + + /** + * Sets parent context for the question. + * + * This may be needed when question is being created, there is no question context but we need to check capabilities + * + * @param \context $context + */ + public function set_parent_context(\context $context): void { + $this->parentcontext = $context; + } + + /** + * Returns the parent context for the question. + * + * @return \context + */ + protected function get_parent_context() : \context { + if ($this->parentcontext) { + return $this->parentcontext; + } else { + return \context_system::instance(); + } + } + + /** + * Context that should be used for new categories created by this handler. + * + * @return \context the context for configuration + */ + public function get_configuration_context() : \context { + return \context_system::instance(); + } + + /** + * URL for configuration page for the fields for the question custom fields. + * + * @return \moodle_url The URL to configure custom fields for this component + */ + public function get_configuration_url() : \moodle_url { + return new \moodle_url('/question/customfield.php'); + } + + /** + * Returns the context for the data associated with the given instanceid. + * + * @param int $instanceid id of the record to get the context for + * @return \context the context for the given record + * @throws \coding_exception + */ + public function get_instance_context(int $instanceid = 0) : \context { + if ($instanceid > 0) { + $questiondata = \question_bank::load_question_data($instanceid); + $contextid = $questiondata->contextid; + $context = \context::instance_by_id($contextid); + return $context; + } else { + throw new \coding_exception('Instance id must be provided.'); + } + } + + /** + * Given a field and instance id get all the filed data. + * + * @param field_controller $field The field to get the data for. + * @param int $instanceid The instance id to get the data for. + * @return \core_customfield\data_controller The fetched data. + */ + public function get_field_data(\core_customfield\field_controller $field, int $instanceid): \core_customfield\data_controller { + $fields = [$field->get('id') => $field]; + $fieldsdata = api::get_instance_fields_data($fields, $instanceid); + return $fieldsdata[$field->get('id')]; + } + + /** + * For a given instance id (question id) get the categories and the + * fields with any data. Return an array of categories containing an + * array of field names and values that is ready to be passed to a renderer. + * + * @param int $instanceid The instance id to get the data for. + * @return array $cfdata The fetched data + */ + public function get_categories_fields_data(int $instanceid): array { + // Prepare custom fields data. + $instancedata = $this->get_instance_data($instanceid); + + $cfdata = []; + + foreach ($instancedata as $instance) { + $field = $instance->get_field(); + + if ($this->can_view($field, $instanceid)) { + $category = $instance->get_field()->get_category()->get('name'); + $fieldname = $field->get_formatted_name(); + $fieldvalue = $this->get_field_data($field, $instanceid)->export_value(); + $cfdata[$category][] = ['name' => $fieldname, 'value' => $fieldvalue]; + } + + } + + return $cfdata; + } + + /** + * Get the custom data for the given field + * and render HTML ready for display in question table. + * + * @param object $fielddata The field data used for display. + * @return string The HTML to display in the table column. + */ + public function display_custom_field_table(object $fielddata) : string { + global $PAGE; + + $output = $PAGE->get_renderer('qbank_customfields'); + $outputdata = new field_data($fielddata); + + return $output->render_for_table($outputdata); + } + + /** + * Render the custom field category and filed data as HTML ready for display. + * + * @param array $catfielddata Array of categories and field names and values. + * @return string The HTML to display. + */ + public function display_custom_categories_fields(array $catfielddata) : string { + global $PAGE; + $output = $PAGE->get_renderer('qbank_customfields'); + + return $output->render_for_preview($catfielddata); + } + + /** + * Add custom controls to the field configuration form that will be saved. + * + * @param \MoodleQuickForm $mform The form to add the custom fields to. + */ + public function config_form_definition(\MoodleQuickForm $mform): void { + $mform->addElement('header', 'question_handler_header', + get_string('customfieldsettings', 'qbank_customfields')); + $mform->setExpanded('question_handler_header', true); + + // If field is locked. + $mform->addElement('selectyesno', 'configdata[locked]', + get_string('customfield_islocked', 'qbank_customfields')); + $mform->addHelpButton('configdata[locked]', 'customfield_islocked', 'qbank_customfields'); + + // Field data visibility. + $visibilityoptions = [ + self::VISIBLETOALL => get_string('customfield_visibletoall', 'qbank_customfields'), + self::VISIBLETOTEACHERS => get_string('customfield_visibletoteachers', 'qbank_customfields'), + self::NOTVISIBLE => get_string('customfield_notvisible', 'qbank_customfields') + ]; + $mform->addElement('select', 'configdata[visibility]', + get_string('customfield_visibility', 'qbank_customfields'), + $visibilityoptions); + $mform->addHelpButton( + 'configdata[visibility]', 'customfield_visibility', 'qbank_customfields'); + } + + /** + * Creates or updates the question custom field data when restoring from a backup. + * + * @param \restore_task $task + * @param array $data + */ + public function restore_instance_data_from_backup(\restore_task $task, array $data): void { + + $editablefields = $this->get_editable_fields($data['newquestion']); + $records = api::get_instance_fields_data($editablefields, $data['newquestion']); + $target = $task->get_target(); + $override = ($target != \backup::TARGET_CURRENT_ADDING && $target != \backup::TARGET_EXISTING_ADDING); + + foreach ($records as $d) { + $field = $d->get_field(); + if ($field->get('shortname') === $data['shortname'] && $field->get('type') === $data['type']) { + if (!$d->get('id') || $override) { + $d->set($d->datafield(), $data['value']); + $d->set('value', $data['value']); + $d->set('valueformat', $data['valueformat']); + $d->set('contextid', $data['fieldcontextid']); + $d->save(); + } + return; + } + } + } +} diff --git a/question/bank/customfields/classes/output/renderer.php b/question/bank/customfields/classes/output/renderer.php new file mode 100644 index 00000000000..249cfc397c9 --- /dev/null +++ b/question/bank/customfields/classes/output/renderer.php @@ -0,0 +1,59 @@ +. + +namespace qbank_customfields\output; + +/** + * Class renderer. + * + * @package qbank_customfields + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class renderer extends \plugin_renderer_base { + + /** + * Render custom field data for table display. + * + * @param object $fielddata The field data to display. + * @return string The rendered HTML. + */ + public function render_for_table(object $fielddata): string { + $context = $fielddata->export_for_template($this); + return $this->render_from_template('qbank_customfields/table_display', $context); + } + + /** + * Render custom filed data for table display. + * + * @param array $catfielddata The category and field data. + * @return string The rendered HTML. + */ + public function render_for_preview(array $catfielddata): string { + $context = ['categories' => []]; + + foreach ($catfielddata as $key => $value) { + $context['categories'][] = [ + 'catname' => $key, + 'fields' => $value + ]; + + } + + return $this->render_from_template('qbank_customfields/preview_display', $context); + } +} diff --git a/question/bank/customfields/classes/plugin_feature.php b/question/bank/customfields/classes/plugin_feature.php new file mode 100644 index 00000000000..10e863a1484 --- /dev/null +++ b/question/bank/customfields/classes/plugin_feature.php @@ -0,0 +1,60 @@ +. + +namespace qbank_customfields; + +use core_question\local\bank\plugin_features_base; +use core_question\local\bank\view; +use qbank_customfields\customfield\question_handler; + +/** + * Class plugin_feature is the entrypoint for the columns. + * + * @package qbank_customfields + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Ghaly Marc-Alexandre + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class plugin_feature extends plugin_features_base { + + /** + * This method will return the array of objects to be rendered as a prt of question bank columns/actions. + * + * @param view $qbank + * @return array + */ + public function get_question_columns(view $qbank): array { + // We make a column for each custom field and load the data into it. + $columns = []; + + // First get all the available question custom fields. + $customfieldhandler = question_handler::create(); + $fields = $customfieldhandler->get_fields(); + $context = $qbank->get_most_specific_context(); + + // Iterate through the fields initialising a column for each. + // We don't need to know the values that questions have at this stage. + foreach ($fields as $field) { + if ($customfieldhandler->can_view_type($field, $context)) { + $customfieldcolumn = new custom_field_column($qbank, $field); + $columns[] = $customfieldcolumn; + } + + } + + return $columns; + } +} diff --git a/question/bank/customfields/classes/privacy/provider.php b/question/bank/customfields/classes/privacy/provider.php new file mode 100644 index 00000000000..7561bbad287 --- /dev/null +++ b/question/bank/customfields/classes/privacy/provider.php @@ -0,0 +1,38 @@ +. + +namespace qbank_customfields\privacy; + +/** + * Privacy Subsystem for qbank_customfields implementing null_provider. + * + * @package qbank_customfields + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\null_provider { + + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason(): string { + return 'privacy:metadata'; + } +} diff --git a/question/bank/customfields/customfield.php b/question/bank/customfields/customfield.php new file mode 100644 index 00000000000..70f5657c292 --- /dev/null +++ b/question/bank/customfields/customfield.php @@ -0,0 +1,38 @@ +. + +/** + * Manage question custom fields + * + * @package qbank_customfields + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once('../../..//config.php'); +require_once($CFG->libdir.'/adminlib.php'); + +admin_externalpage_setup('qbank_customfields'); + +$output = $PAGE->get_renderer('core_customfield'); +$customfieldhandler = qbank_customfields\customfield\question_handler::create(); +$outputpage = new \core_customfield\output\management($customfieldhandler); + +echo $output->header(), + $output->heading(new lang_string('pluginname', 'qbank_customfields')), + $output->render($outputpage), + $output->footer(); diff --git a/question/bank/customfields/db/access.php b/question/bank/customfields/db/access.php new file mode 100644 index 00000000000..e558c655647 --- /dev/null +++ b/question/bank/customfields/db/access.php @@ -0,0 +1,56 @@ +. + +/** + * Plugin capabilities are defined here. + * + * @package qbank_customfields + * @category access + * @copyright 2021 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$capabilities = [ + // Controls if a user can can change the value of a locked custom field. + 'qbank/customfields:changelockedcustomfields' => [ + 'riskbitmask' => RISK_SPAM, + 'captype' => 'write', + 'contextlevel' => CONTEXT_COURSE, + 'archetypes' => [ + 'manager' => CAP_ALLOW + ], + ], + + // Controls if a user can configure question custom fields. + 'qbank/customfields:configurecustomfields' => [ + 'riskbitmask' => RISK_SPAM, + 'captype' => 'write', + 'contextlevel' => CONTEXT_SYSTEM, + 'clonepermissionsfrom' => 'moodle/site:config' + ], + + // Controls if a user can view the content of hidden custom fields. + 'qbank/customfields:viewhiddencustomfields' => [ + 'captype' => 'read', + 'contextlevel' => CONTEXT_COURSE, + 'archetypes' => [ + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW + ], + ], +]; diff --git a/question/bank/customfields/lang/en/qbank_customfields.php b/question/bank/customfields/lang/en/qbank_customfields.php new file mode 100644 index 00000000000..92287dd081d --- /dev/null +++ b/question/bank/customfields/lang/en/qbank_customfields.php @@ -0,0 +1,40 @@ +. + +/** + * Strings for component qbank_customfields, language 'en'. + * + * @package qbank_customfields + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['pluginname'] = 'Question custom fields'; +$string['privacy:metadata'] = 'Question custom fields plugin does not store any user data.'; + +$string['customfield'] = 'Question custom fields'; +$string['customfield_islocked'] = 'Locked'; +$string['customfield_islocked_help'] = 'If the field is locked, only users with the capability to change locked custom fields will be able to change it in the question settings.'; +$string['customfield_notvisible'] = 'Nobody'; +$string['customfield_visibility'] = 'Visible to'; +$string['customfield_visibility_help'] = 'This setting determines who can view the custom field name and value.'; +$string['customfield_visibletoall'] = 'Everyone'; +$string['customfield_visibletoteachers'] = 'Teachers'; +$string['customfieldsettings'] = 'Common question custom fields settings'; +$string['customfields:changelockedcustomfields'] = 'Change locked custom fields'; +$string['customfields:configurecustomfields'] = 'Configure custom fields'; +$string['customfields:viewhiddencustomfields'] = 'View hidden custom fields'; diff --git a/question/bank/customfields/lib.php b/question/bank/customfields/lib.php new file mode 100644 index 00000000000..5ed86978097 --- /dev/null +++ b/question/bank/customfields/lib.php @@ -0,0 +1,40 @@ +. + +/** + * Helper functions and callbacks. + * + * @package qbank_customfields + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Comment content for callbacks. + * + * @param question_definition $question + * @param int $courseid + * @return string + */ +function qbank_customfields_preview_display(question_definition $question, int $courseid): string { + // Prepare custom fields data. + $customfieldhandler = qbank_customfields\customfield\question_handler::create(); + $catfielddata = $customfieldhandler->get_categories_fields_data($question->id); + return $customfieldhandler->display_custom_categories_fields($catfielddata); +} diff --git a/question/bank/customfields/settings.php b/question/bank/customfields/settings.php new file mode 100644 index 00000000000..bbca5d11955 --- /dev/null +++ b/question/bank/customfields/settings.php @@ -0,0 +1,38 @@ +. + +/** + * Plugin administration pages are defined here. + * + * @package qbank_customfields + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +if ($hassiteconfig) { + // Settings for question custom fields. + $settings = null; + $ADMIN->add('qbanksettings', + new admin_externalpage('qbank_customfields', + new lang_string('pluginname', 'qbank_customfields'), + $CFG->wwwroot . '/question/bank/customfields/customfield.php', + ['qbank/customfields:configurecustomfields'] + ) + ); +} diff --git a/question/bank/customfields/templates/preview_display.mustache b/question/bank/customfields/templates/preview_display.mustache new file mode 100644 index 00000000000..0bcc0e555eb --- /dev/null +++ b/question/bank/customfields/templates/preview_display.mustache @@ -0,0 +1,38 @@ +{{! + 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 qbank_customfields/preview_display + + Example context (json): + {"categories":[ + { "catname":"Other fields", + "fields":[ + {"name":"checkit", "value":"No"}, + {"name":"datetime", "value":"1 October 2021"} + ] + } + ]} +}} +

{{#str}} customfield, qbank_customfields {{/str}}

+{{#categories}} +

{{catname}}

+ {{#fields}} +
+ {{{name}}}: {{{value}}} +
+ {{/fields}} +{{/categories}} diff --git a/question/bank/customfields/templates/table_display.mustache b/question/bank/customfields/templates/table_display.mustache new file mode 100644 index 00000000000..e15c08cc343 --- /dev/null +++ b/question/bank/customfields/templates/table_display.mustache @@ -0,0 +1,31 @@ +{{! + 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 qbank_customfields/table_display + + Example context (json): + { + "ftype" : "text", + "shortname" : "nickname", + "value" : "Star Lord" + } +}} +{{#hasvalue}} +
+ {{{value}}} +
+{{/hasvalue}} diff --git a/question/bank/customfields/tests/behat/customfield_question.feature b/question/bank/customfields/tests/behat/customfield_question.feature new file mode 100644 index 00000000000..aeecd4c27d2 --- /dev/null +++ b/question/bank/customfields/tests/behat/customfield_question.feature @@ -0,0 +1,60 @@ +@core @core_question @core_customfield @qbank_customfields @javascript +Feature: A teacher can edit question with custom fields + In order to improve my questions + As a teacher + I need to be able to edit questions and add extra metadata via custom fields + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | weeks | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + And the following "custom field categories" exist: + | name | component | area | itemid | + | Category for test | qbank_customfields | question | 0 | + And the following "custom fields" exist: + | name | category | type | shortname | + | Field 1 | Category for test | text | f1 | + And the following "activity" exists: + | activity | quiz | + | course | C1 | + | idnumber | 00001 | + | name | Test quiz name | + | intro | Test quiz description | + | section | 1 | + | grade | 10 | + And I log in as "teacher1" + And I am on "Course 1" course homepage with editing mode on + And I add a "True/False" question to the "Test quiz name" quiz with: + | Question name | First question | + | Question text | Answer the first question | + | General feedback | Thank you, this is the general feedback | + | Correct answer | False | + | Feedback for the response 'True'. | So you think it is true | + | Feedback for the response 'False'. | So you think it is false | + And I am on the "Test quiz name" "quiz activity" page + And I navigate to "Question bank > Questions" in current page administration + + Scenario: Edit a previously created question and see the custom field in the overview table and in the question preview. + When I choose "Edit question" action for "First question" in the question bank + And I should see "Category for test" + And I click on "Expand all" "link" + And I should see "Field 1" + And I set the following fields to these values: + | Field 1 | custom field text | + And I press "id_submitbutton" + And I should see "First question" + And I should see "custom field text" + And I choose "Preview" action for "First question" in the question bank + And I should see "Field 1" + Then I should see "custom field text" + + Scenario: Preview a previously created question with custom fields set with empty values + When I choose "Preview" action for "First question" in the question bank + And I should see "Field 1" + Then I should not see "custom field text" diff --git a/question/bank/customfields/tests/behat/customfield_question_setup.feature b/question/bank/customfields/tests/behat/customfield_question_setup.feature new file mode 100644 index 00000000000..50a414b3988 --- /dev/null +++ b/question/bank/customfields/tests/behat/customfield_question_setup.feature @@ -0,0 +1,98 @@ +@core @core_question @core_customfield @qbank_customfields @javascript +Feature: Site administrators can manage categories for question custom fields + In order to have additional data in questions + As a site site administrator + I need to create, edit, remove and sort question custom field categories + + Scenario: Create a category for custom question fields + Given I log in as "admin" + When I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration + And I press "Add a new category" + And I wait until the page is ready + Then I should see "Other fields" in the "#customfield_catlist" "css_element" + + Scenario: Edit a category name for custom question fields + Given the following "custom field categories" exist: + | name | component | area | itemid | + | Category for test | qbank_customfields | question | 0 | + And I log in as "admin" + And I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration + And I set the field "Edit category name" in the "//div[contains(@class,'categoryinstance') and contains(.,'Category for test')]" "xpath_element" to "Good fields" + Then I should not see "Category for test" in the "#customfield_catlist" "css_element" + And "New value for Category for test" "field" should not exist + And I should see "Good fields" in the "#customfield_catlist" "css_element" + + Scenario: Delete a category for custom question fields + Given the following "custom field categories" exist: + | name | component | area | itemid | + | Category for test | qbank_customfields | question | 0 | + And the following "custom fields" exist: + | name | category | type | shortname | + | Field 1 | Category for test | text | f1 | + And I log in as "admin" + And I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration + And I click on "[data-role='deletecategory']" "css_element" + And I click on "Yes" "button" in the "Confirm" "dialogue" + And I wait until the page is ready + And I wait until "Test category" "text" does not exist + Then I should not see "Test category" in the "#customfield_catlist" "css_element" + + Scenario: Move field in the question custom fields to another category + Given the following "custom field categories" exist: + | name | component | area | itemid | + | Category1 | qbank_customfields | question | 0 | + | Category2 | qbank_customfields | question | 0 | + | Category3 | qbank_customfields | question | 0 | + And the following "custom fields" exist: + | name | category | type | shortname | + | Field1 | Category1 | text | f1 | + | Field2 | Category2 | text | f2 | + When I log in as "admin" + And I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration + Then "Field1" "text" should appear after "Category1" "text" + And "Category2" "text" should appear after "Field1" "text" + And "Field2" "text" should appear after "Category2" "text" + And "Category3" "text" should appear after "Field2" "text" + And I press "Move \"Field1\"" + And I follow "To the top of category Category2" + And "Category2" "text" should appear after "Category1" "text" + And "Field1" "text" should appear after "Category2" "text" + And "Field2" "text" should appear after "Field1" "text" + And "Category3" "text" should appear after "Field2" "text" + And I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration + And "Category2" "text" should appear after "Category1" "text" + And "Field1" "text" should appear after "Category2" "text" + And "Field2" "text" should appear after "Field1" "text" + And "Category3" "text" should appear after "Field2" "text" + And I press "Move \"Field1\"" + And I follow "After field Field2" + And "Field1" "text" should appear after "Field2" "text" + + Scenario: Reorder question custom field categories + Given the following "custom field categories" exist: + | name | component | area | itemid | + | Category1 | qbank_customfields | question | 0 | + | Category2 | qbank_customfields | question | 0 | + | Category3 | qbank_customfields | question | 0 | + And the following "custom fields" exist: + | name | category | type | shortname | + | Field1 | Category1 | text | f1 | + When I log in as "admin" + And I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration + Then "Field1" "text" should appear after "Category1" "text" + And "Category2" "text" should appear after "Field1" "text" + And "Category3" "text" should appear after "Category2" "text" + And I press "Move \"Category2\"" + And I follow "After \"Category3\"" + And "Field1" "text" should appear after "Category1" "text" + And "Category3" "text" should appear after "Field1" "text" + And "Category2" "text" should appear after "Category3" "text" + And I navigate to "Plugins > Question bank plugins > Question custom fields" in site administration + And "Field1" "text" should appear after "Category1" "text" + And "Category3" "text" should appear after "Field1" "text" + And "Category2" "text" should appear after "Category3" "text" + And I press "Move \"Category2\"" + And I follow "After \"Category1\"" + And "Field1" "text" should appear after "Category1" "text" + And "Category2" "text" should appear after "Field1" "text" + And "Category3" "text" should appear after "Category2" "text" diff --git a/question/bank/customfields/tests/behat/customfield_question_visbility.feature b/question/bank/customfields/tests/behat/customfield_question_visbility.feature new file mode 100644 index 00000000000..0c78fc8599f --- /dev/null +++ b/question/bank/customfields/tests/behat/customfield_question_visbility.feature @@ -0,0 +1,67 @@ +@core @core_quiz @core_customfield @qbank_customfields @javascript @xxpriority +Feature: The visibility of question custom fields control where they are displayed + In order to display custom fields in a quiz + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Terry1 | Teacher1 | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | weeks | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + And the following "custom field categories" exist: + | name | component | area | itemid | + | Category for test | qbank_customfields | question | 0 | + And the following "custom fields" exist: + | name | category | type | shortname | configdata | + | Field 1 | Category for test | text | f1 | {"visibility":"2"} | + | Field 2 | Category for test | text | f2 | {"visibility":"2"} | + | Field 3 | Category for test | text | f3 | {"visibility":"2","defaultvalue":"secret"} | + And the following "activity" exists: + | activity | quiz | + | course | C1 | + | idnumber | 00001 | + | name | Test quiz name | + | intro | Test quiz description | + | section | 1 | + | grade | 10 | + When I log in as "teacher1" + And I am on "Course 1" course homepage with editing mode on + And I add a "True/False" question to the "Test quiz name" quiz with: + | Question name | First question | + | Question text | Answer the first question | + | General feedback | Thank you, this is the general feedback | + | Correct answer | False | + | Feedback for the response 'True'. | So you think it is true | + | Feedback for the response 'False'. | So you think it is false | + And I am on the "Test quiz name" "quiz activity" page + And I navigate to "Question bank > Questions" in current page administration + + @javascript + Scenario: Display custom question fields to teachers based on their visibility. + When I choose "Edit question" action for "First question" in the question bank + And I should see "Category for test" + And I click on "Expand all" "link" + And I should see "Field 1" + And I should see "Field 2" + And I should see "Field 3" + And I set the following fields to these values: + | Field 1 | custom field text one| + | Field 2 | custom field text two| + And I press "id_submitbutton" + And I should see "Field 1" + And I should see "custom field text one" + And I should see "Field 2" + And I should see "custom field text two" + And I should see "Field 3" + And I should see "secret" + And I choose "Preview" action for "First question" in the question bank + And I should see "Field 1" + And I should see "custom field text one" + And I should see "Field 2" + And I should see "custom field text two" + And I should see "Field 3" + Then I should see "secret" diff --git a/question/bank/customfields/tests/customfield_test.php b/question/bank/customfields/tests/customfield_test.php new file mode 100644 index 00000000000..cb7df9109f6 --- /dev/null +++ b/question/bank/customfields/tests/customfield_test.php @@ -0,0 +1,306 @@ +. + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); +require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); + +/** + * Class qbank_customfields_customfield_testcase + * + * @package qbank_customfields + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qbank_customfields_customfield_testcase extends advanced_testcase { + + /** + * @var array Data object for generating a question. + */ + protected $question1data; + + /** + * @var array Data object for generating a question. + */ + protected $question2data; + + /** + * @var component_generator_base Question Generator. + */ + protected $qgen; + + /** + * @var core_course_category Course category. + */ + protected $category; + + /** + * @var stdClass Course object. + */ + protected $course; + + /** + * @var int Timestamp to use in tests. + */ + protected $testnow = 1632278491; + + /** + * Helper to assist with setting up custom fields. + * This is creating custom field category and the fields, not adding instance field data. + */ + protected function setup_custom_fields(): void { + + $dg = self::getDataGenerator(); + $data = new \stdClass(); + $data->component = 'qbank_customfields'; + $data->area = 'question'; + + $catid = $dg->create_custom_field_category($data)->get('id'); + $dg->create_custom_field(['categoryid' => $catid, 'type' => 'text', 'shortname' => 'f1']); + $dg->create_custom_field(['categoryid' => $catid, 'type' => 'checkbox', 'shortname' => 'f2']); + $dg->create_custom_field(['categoryid' => $catid, 'type' => 'date', 'shortname' => 'f3', + 'configdata' => ['startyear' => 2000, 'endyear' => 3000, 'includetime' => 1]]); + $dg->create_custom_field(['categoryid' => $catid, 'type' => 'select', 'shortname' => 'f4', + 'configdata' => ['options' => "a\nb\nc"]]); + $dg->create_custom_field(['categoryid' => $catid, 'type' => 'textarea', 'shortname' => 'f5']); + + } + + /** + * Helper to assist with setting up questions used in tests. + */ + protected function setup_questions(): void { + // Question initial set up. + $this->category = $this->getDataGenerator()->create_category(); + $this->course = $this->getDataGenerator()->create_course(['category' => $this->category->id]); + $context = context_coursecat::instance($this->category->id); + $this->qgen = $this->getDataGenerator()->get_plugin_generator('core_question'); + $qcat = $this->qgen->create_question_category(['contextid' => $context->id]); + + $this->question1data = [ + 'category' => $qcat->id, 'idnumber' => 'q1', + 'customfield_f1' => 'some text', 'customfield_f2' => 1, + 'customfield_f3' => $this->testnow, 'customfield_f4' => 2, + 'customfield_f5_editor' => ['text' => 'test', 'format' => FORMAT_HTML]]; + + $this->question2data = [ + 'category' => $qcat->id, 'idnumber' => 'q2', + 'customfield_f1' => 'some more text', 'customfield_f2' => 0, + 'customfield_f3' => $this->testnow, 'customfield_f4' => 1, + 'customfield_f5_editor' => ['text' => 'test text', 'format' => FORMAT_HTML]]; + } + + /** + * Makes a backup of the course. + * + * @param stdClass $course The course object. + * @return string Unique identifier for this backup. + */ + protected function backup_course(\stdClass $course): string { + global $CFG, $USER; + + // Turn off file logging, otherwise it can't delete the file (Windows). + $CFG->backup_file_logger_level = backup::LOG_NONE; + + // Do backup with default settings. MODE_IMPORT means it will just + // create the directory and not zip it. + $bc = new backup_controller(backup::TYPE_1COURSE, $course->id, + backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_IMPORT, + $USER->id); + $backupid = $bc->get_backupid(); + $bc->execute_plan(); + $bc->destroy(); + + return $backupid; + } + + /** + * Restores a backup that has been made earlier. + * + * @param string $backupid The unique identifier of the backup. + * @param string $fullname Full name of the new course that is going to be created. + * @param string $shortname Short name of the new course that is going to be created. + * @param int $categoryid The course category the backup is going to be restored in. + * @return int The new course id. + */ + protected function restore_course(string $backupid, string $fullname, string $shortname, int $categoryid): int { + global $CFG, $USER; + + // Turn off file logging, otherwise it can't delete the file (Windows). + $CFG->backup_file_logger_level = backup::LOG_NONE; + + // Do restore to new course with default settings. + $newcourseid = restore_dbops::create_new_course($fullname, $shortname, $categoryid); + $rc = new restore_controller($backupid, $newcourseid, + backup::INTERACTIVE_NO, backup::MODE_GENERAL, $USER->id, + backup::TARGET_NEW_COURSE); + + $rc->execute_precheck(); + $rc->execute_plan(); + $rc->destroy(); + + return $newcourseid; + } + + /** + * Test creating questions with custom fields. + */ + public function test_create_question(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + $this->setup_custom_fields(); + $this->setup_questions(); + + // Create 2 questions. + $question1 = $this->qgen->create_question('shortanswer', null, $this->question1data); + $question2 = $this->qgen->create_question('shortanswer', null, $this->question2data); + + // Explicitly save the custom field data for the questions, like a form would. + $customfieldhandler = \qbank_customfields\customfield\question_handler::create(); + $this->question1data['id'] = $question1->id; + $this->question2data['id'] = $question2->id; + $customfieldhandler->instance_form_save((object)$this->question1data); + $customfieldhandler->instance_form_save((object)$this->question2data); + + // Get the custom field data associated with these question ids. + $q1cfdata = $customfieldhandler->export_instance_data_object($question1->id); + $q2cfdata = $customfieldhandler->export_instance_data_object($question2->id); + + $this->assertEquals('some text', $q1cfdata->f1); + $this->assertEquals('Yes', $q1cfdata->f2); + $this->assertEquals(userdate($this->testnow, get_string('strftimedaydatetime')), $q1cfdata->f3); + $this->assertEquals('b', $q1cfdata->f4); + $this->assertEquals('test', $q1cfdata->f5); + + $this->assertEquals('some more text', $q2cfdata->f1); + $this->assertEquals('No', $q2cfdata->f2); + $this->assertEquals(userdate($this->testnow, get_string('strftimedaydatetime')), $q2cfdata->f3); + $this->assertEquals('a', $q2cfdata->f4); + $this->assertEquals('test text', $q2cfdata->f5); + } + + /** + * Test deleting questions with custom fields. + */ + public function test_delete_question(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + $this->setup_custom_fields(); + $this->setup_questions(); + + // Create 2 questions. + $question1 = $this->qgen->create_question('shortanswer', null, $this->question1data); + $question2 = $this->qgen->create_question('shortanswer', null, $this->question2data); + + // Explicitly save the custom field data for the questions, like a form would. + $customfieldhandler = \qbank_customfields\customfield\question_handler::create(); + $this->question1data['id'] = $question1->id; + $this->question2data['id'] = $question2->id; + $customfieldhandler->instance_form_save((object)$this->question1data); + $customfieldhandler->instance_form_save((object)$this->question2data); + + // Get the custom field data associated with these question ids. + $q1cfdata = $customfieldhandler->export_instance_data_object($question1->id); + $q2cfdata = $customfieldhandler->export_instance_data_object($question2->id); + + // Quick check that we have data for the custom fields. + $this->assertEquals('some text', $q1cfdata->f1); + $this->assertEquals('some more text', $q2cfdata->f1); + + // Delete the questions. + question_delete_question($question1->id); + question_delete_question($question2->id); + + // Check the custom field data for the questions has also gone. + $q1cfdata = $customfieldhandler->export_instance_data_object($question1->id); + $q2cfdata = $customfieldhandler->export_instance_data_object($question2->id); + + $this->assertEmpty($q1cfdata->f1); + $this->assertEmpty($q2cfdata->f1); + } + + /** + * Test custom fields attached to questions persist + * across the backup and restore process. + */ + public function test_backup_restore(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + $this->setup_custom_fields(); + $this->setup_questions(); + + $courseshortname = $this->course->shortname; + $coursefullname = $this->course->fullname; + + // Create 2 questions. + $question1 = $this->qgen->create_question('shortanswer', null, $this->question1data); + $question2 = $this->qgen->create_question('shortanswer', null, $this->question2data); + + // Explicitly save the custom field data for the questions, like a form would. + $customfieldhandler = \qbank_customfields\customfield\question_handler::create(); + $this->question1data['id'] = $question1->id; + $this->question2data['id'] = $question2->id; + $customfieldhandler->instance_form_save((object)$this->question1data); + $customfieldhandler->instance_form_save((object)$this->question2data); + + // Create a quiz and the questions to that. + $quiz = $this->getDataGenerator()->create_module( + 'quiz', ['course' => $this->course->id, 'name' => 'restored_quiz']); + quiz_add_quiz_question($question1->id, $quiz); + quiz_add_quiz_question($question2->id, $quiz); + + // Backup the course. + $backupid = $this->backup_course($this->course); + + // Now delete everything. + delete_course($this->course, false); + question_delete_question($question1->id); + question_delete_question($question2->id); + + // Check the custom field data for the questions has also gone. + $q1cfdata = $customfieldhandler->export_instance_data_object($question1->id); + $q2cfdata = $customfieldhandler->export_instance_data_object($question2->id); + + $this->assertEmpty($q1cfdata->f1); + $this->assertEmpty($q2cfdata->f1); + + // Restore the backup we had made earlier into a new course. + $newcategory = $this->getDataGenerator()->create_category(); + $this->restore_course($backupid, $coursefullname, $courseshortname . '_2', $newcategory->id); + + // The questions and their associated custom fields should have been restored. + $newquestion1 = $DB->get_record('question', ['idnumber' => 'q1']); + $newquestion1cfdata = $customfieldhandler->export_instance_data_object($newquestion1->id); + $this->assertEquals('some text', $newquestion1cfdata->f1); + $this->assertEquals('Yes', $newquestion1cfdata->f2); + $this->assertEquals(userdate($this->testnow, get_string('strftimedaydatetime')), $newquestion1cfdata->f3); + $this->assertEquals('b', $newquestion1cfdata->f4); + $this->assertEquals('test', $newquestion1cfdata->f5); + + $newquestion2 = $DB->get_record('question', ['idnumber' => 'q2']); + $newquestion2cfdata = $customfieldhandler->export_instance_data_object($newquestion2->id); + $this->assertEquals('some more text', $newquestion2cfdata->f1); + $this->assertEquals('No', $newquestion2cfdata->f2); + $this->assertEquals(userdate($this->testnow, get_string('strftimedaydatetime')), $newquestion2cfdata->f3); + $this->assertEquals('a', $newquestion2cfdata->f4); + $this->assertEquals('test text', $newquestion2cfdata->f5); + } +} diff --git a/question/bank/customfields/tests/question_handler_test.php b/question/bank/customfields/tests/question_handler_test.php new file mode 100644 index 00000000000..3d37241dd7b --- /dev/null +++ b/question/bank/customfields/tests/question_handler_test.php @@ -0,0 +1,143 @@ +. + +/** + * Class qbank_customfields_question_handler_testcase + * + * @package qbank_customfields + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qbank_customfields_question_handler_testcase extends advanced_testcase { + + /** + * Question setup helper method. + * + * @return int The question id. + * @throws coding_exception + */ + protected function setup_question(): int { + $category = $this->getDataGenerator()->create_category(); + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $context = context_coursecat::instance($category->id); + $questioncategory = $questiongenerator->create_question_category(['contextid' => $context->id]); + $questiondata = ['category' => $questioncategory->id, 'idnumber' => 'q1']; + $question = $questiongenerator->create_question('shortanswer', null, $questiondata); + + return $question->id; + + } + + /** + * Test custom field data. + */ + public function test_get_field_data(): void { + $this->resetAfterTest(); + $fieldgenerator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); + + $instanceid = $this->setup_question(); + $fieldvalue = 'test field text'; + + $categorydata = new \stdClass(); + $categorydata->component = 'qbank_customfields'; + $categorydata->area = 'question'; + $categorydata->name = 'test category'; + + $customfieldcatid = $fieldgenerator->create_category($categorydata)->get('id'); + $field = $fieldgenerator->create_field(['categoryid' => $customfieldcatid, 'type' => 'text', 'shortname' => 'f1']); + $fieldgenerator->add_instance_data($field, $instanceid, $fieldvalue); + + // Get the field data. + $customfieldhandler = qbank_customfields\customfield\question_handler::create(); + $fieldinstancedata = $customfieldhandler->get_field_data($field, $instanceid); + + $this->assertEquals($categorydata->name, $fieldinstancedata->get_field()->get_category()->get('name')); + $this->assertEquals($fieldvalue, $fieldinstancedata->get_value()); + } + + /** + * Test getting custom field data for table display. + */ + public function test_display_custom_field_table(): void { + $this->resetAfterTest(); + $fieldgenerator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); + + $instanceid = $this->setup_question(); + $fieldvalue = 'test field text'; + + $categorydata = new \stdClass(); + $categorydata->component = 'qbank_customfields'; + $categorydata->area = 'question'; + $categorydata->name = 'test category'; + + $customfieldcatid = $fieldgenerator->create_category($categorydata)->get('id'); + $field = $fieldgenerator->create_field(['categoryid' => $customfieldcatid, 'type' => 'text', 'shortname' => 'f1']); + $fieldgenerator->add_instance_data($field, $instanceid, $fieldvalue); + + // Get the field data. + $customfieldhandler = qbank_customfields\customfield\question_handler::create(); + $fieldinstancedata = $customfieldhandler->get_field_data($field, $instanceid); + $output = $customfieldhandler->display_custom_field_table($fieldinstancedata); + + $this->assertStringContainsString($fieldvalue, $output); + } + + /** + * Test getting categories and field data for a specific instance. + */ + public function test_get_categories_fields_data(): void { + $this->resetAfterTest(); + $fieldgenerator = $this->getDataGenerator()->get_plugin_generator('core_customfield'); + + $instanceid = $this->setup_question(); + $field1value = 'first field text'; + $field2value = 'second field text'; + $field3value = 'third field text'; + + $categorydata = new \stdClass(); + $categorydata->component = 'qbank_customfields'; + $categorydata->area = 'question'; + $categorydata->name = 'test category'; + + $customfieldcat1id = $fieldgenerator->create_category($categorydata)->get('id'); + $categorydata->name = 'test category two'; + $customfieldcat2id = $fieldgenerator->create_category($categorydata)->get('id'); + + $field1 = $fieldgenerator->create_field(['categoryid' => $customfieldcat1id, 'type' => 'text', 'shortname' => 'f1']); + $fieldgenerator->add_instance_data($field1, $instanceid, $field1value); + $field2 = $fieldgenerator->create_field(['categoryid' => $customfieldcat1id, 'type' => 'text', 'shortname' => 'f2']); + $fieldgenerator->add_instance_data($field2, $instanceid, $field2value); + + $field3 = $fieldgenerator->create_field(['categoryid' => $customfieldcat2id, 'type' => 'text', 'shortname' => 'f3']); + $fieldgenerator->add_instance_data($field3, $instanceid, $field3value); + + // Get the field data. + $customfieldhandler = qbank_customfields\customfield\question_handler::create(); + $outputdata = $customfieldhandler->get_categories_fields_data($instanceid); + + $this->assertEquals($field1value, $outputdata['test category'][0]['value']); + $this->assertEquals($field2value, $outputdata['test category'][1]['value']); + $this->assertEquals($field3value, $outputdata['test category two'][0]['value']); + + // While we're here, lets test the rendering of this data. + $outputhtml = $customfieldhandler->display_custom_categories_fields($outputdata); + + $this->assertStringContainsString($field1value, $outputhtml); + $this->assertStringContainsString($field2value, $outputhtml); + $this->assertStringContainsString($field3value, $outputhtml); + } +} diff --git a/question/bank/customfields/version.php b/question/bank/customfields/version.php new file mode 100644 index 00000000000..fa3a75742cf --- /dev/null +++ b/question/bank/customfields/version.php @@ -0,0 +1,31 @@ +. + +/** + * Version information for qbank_customfields. + * + * @package qbank_customfields + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->component = 'qbank_customfields'; +$plugin->version = 2021100500; +$plugin->requires = 2021052500; +$plugin->maturity = MATURITY_STABLE; diff --git a/question/bank/editquestion/question.php b/question/bank/editquestion/question.php index 3a5384e5dc4..aa1b68380b7 100644 --- a/question/bank/editquestion/question.php +++ b/question/bank/editquestion/question.php @@ -219,6 +219,10 @@ if ($cm !== null) { $toform->inpopup = $inpopup; +// Prepare custom fields data. +$customfieldhandler = qbank_customfields\customfield\question_handler::create(); +$customfieldhandler->instance_form_before_set_data($toform); + $mform->set_data($toform); if ($mform->is_cancelled()) { @@ -281,6 +285,9 @@ if ($mform->is_cancelled()) { context_course::instance($fromform->courseid), $fromform->coursetags, 0); } + // Update custom fields if there are any of them in the form. + $customfieldhandler->instance_form_save($fromform); + // Purge this question from the cache. question_bank::notify_question_edited($question->id); diff --git a/question/bank/viewquestionname/classes/viewquestionname_column_helper.php b/question/bank/viewquestionname/classes/viewquestionname_column_helper.php index f4f6def1307..22d02421207 100644 --- a/question/bank/viewquestionname/classes/viewquestionname_column_helper.php +++ b/question/bank/viewquestionname/classes/viewquestionname_column_helper.php @@ -44,7 +44,7 @@ class viewquestionname_column_helper extends column_base { protected function label_for($question): string { if (is_null($this->checkboxespresent)) { - $this->checkboxespresent = $this->qbank->has_column('core_question\local\bank\checkbox_column'); + $this->checkboxespresent = $this->qbank->has_column('checkbox_column'); } if ($this->checkboxespresent) { return 'checkq' . $question->id; diff --git a/question/classes/local/bank/column_base.php b/question/classes/local/bank/column_base.php index 24aa63c76c1..faaa2a5c76a 100644 --- a/question/classes/local/bank/column_base.php +++ b/question/classes/local/bank/column_base.php @@ -232,6 +232,7 @@ abstract class column_base { protected function get_classes(): string { $classes = $this->get_extra_classes(); $classes[] = $this->get_name(); + $classes[] = 'pr-3'; return implode(' ', $classes); } @@ -243,6 +244,17 @@ abstract class column_base { */ abstract public function get_name(); + /** + * Get the name of this column. This must be unique. + * When using the inherited class to make many columns from one parent, + * ensure each instance returns a unique value. + * + * @return string The unique name; + */ + public function get_column_name() { + return (new \ReflectionClass($this))->getShortName(); + } + /** * Any extra class names you would like applied to every cell in this column. * diff --git a/question/classes/local/bank/view.php b/question/classes/local/bank/view.php index fb1405e80de..da5179f9154 100644 --- a/question/classes/local/bank/view.php +++ b/question/classes/local/bank/view.php @@ -263,30 +263,30 @@ class view { $plugins = \core_component::get_plugin_list_with_class('qbank', 'plugin_feature', 'plugin_feature.php'); foreach ($plugins as $componentname => $plugin) { $pluginentrypointobject = new $plugin(); - $pluginobjects = $pluginentrypointobject->get_question_columns($this); + $plugincolumnobjects = $pluginentrypointobject->get_question_columns($this); // Don't need the plugins without column objects. - if (empty($pluginobjects)) { + if (empty($plugincolumnobjects)) { unset($plugins[$componentname]); continue; } - foreach ($pluginobjects as $pluginobject) { - $classname = new \ReflectionClass(get_class($pluginobject)); + foreach ($plugincolumnobjects as $columnobject) { + $columnname = $columnobject->get_column_name(); foreach ($corequestionbankcolumns as $key => $corequestionbankcolumn) { if (!\core\plugininfo\qbank::is_plugin_enabled($componentname)) { - unset($questionbankclasscolumns[$classname->getShortName()]); + unset($questionbankclasscolumns[$columnname]); continue; } // Check if it has custom preference selector to view/hide. - if ($pluginobject->has_preference()) { - if (!$pluginobject->get_preference()) { + if ($columnobject->has_preference()) { + if (!$columnobject->get_preference()) { continue; } } - if ($corequestionbankcolumn === $classname->getShortName()) { - $questionbankclasscolumns[$classname->getShortName()] = $pluginobject; + if ($corequestionbankcolumn === $columnname) { + $questionbankclasscolumns[$columnname] = $columnobject; } else { // Any community plugin for column/action. - $newpluginclasscolumns[$classname->getShortName()] = $pluginobject; + $newpluginclasscolumns[$columnname] = $columnobject; } } } @@ -319,7 +319,7 @@ class view { if (empty($classobject)) { continue; } - $this->requiredcolumns[get_class($classobject)] = $classobject; + $this->requiredcolumns[$classobject->get_column_name()] = $classobject; } return $this->requiredcolumns; @@ -366,9 +366,9 @@ class view { $this->extrarows = []; foreach ($wanted as $column) { if ($column->is_extra_row()) { - $this->extrarows[get_class($column)] = $column; + $this->extrarows[$column->get_column_name()] = $column; } else { - $this->visiblecolumns[get_class($column)] = $column; + $this->visiblecolumns[$column->get_column_name()] = $column; } } @@ -904,7 +904,7 @@ class view { protected function create_new_question_form($category, $canadd): void { if (\core\plugininfo\qbank::is_plugin_enabled('qbank_editquestion')) { echo editquestion_helper::create_new_question_button($category->id, - $this->requiredcolumns['qbank_editquestion\edit_action_column']->editquestionurl->params(), $canadd); + $this->requiredcolumns['edit_action_column']->editquestionurl->params(), $canadd); } } @@ -1115,7 +1115,7 @@ class view { */ protected function start_table() { debugging('Function start_table() is deprecated, please use print_table() instead.', DEBUG_DEVELOPER); - echo '' . "\n"; + echo '
' . "\n"; echo "\n"; $this->print_table_headers(); echo "\n"; diff --git a/question/tests/behat/sort_questions.feature b/question/tests/behat/sort_questions.feature index 4da9c7965cb..adb56b2aae1 100644 --- a/question/tests/behat/sort_questions.feature +++ b/question/tests/behat/sort_questions.feature @@ -32,7 +32,8 @@ Feature: The questions in the question bank can be sorted in various ways @javascript Scenario: The questions can be sorted by idnumber - When I follow "Sort by ID number ascending" + When I change the window size to "large" + And I follow "Sort by ID number ascending" Then "C question 3 name" "checkbox" should appear after "A question 1 name" "checkbox" And I should see "numidnumadd_tag_fields($mform); } + // Add custom fields to the form. + $customfieldhandler = qbank_customfields\customfield\question_handler::create(); + $customfieldhandler->set_parent_context($this->categorycontext); // For question handler only. + $customfieldhandler->instance_form_definition($mform, empty($this->question->id) ? 0 : $this->question->id); + if (!empty($this->question->id)) { $mform->addElement('header', 'createdmodifiedheader', get_string('createdmodifiedheader', 'question')); @@ -285,6 +290,16 @@ abstract class question_edit_form extends question_wizard_form { // By default, do nothing. } + /** + * Tweak the form with values provided by custom fields in use. + */ + public function definition_after_data() { + $mform = $this->_form; + + $customfieldhandler = qbank_customfields\customfield\question_handler::create(); + $customfieldhandler->instance_form_definition_after_data($mform, empty($this->question->id) ? 0 : $this->question->id); + } + /** * Is the question being edited in a state where it can be previewed? * @return bool whether to show the preview link. @@ -855,6 +870,10 @@ abstract class question_edit_form extends question_wizard_form { } } + // Add the custom field validation. + $customfieldhandler = core_course\customfield\course_handler::create(); + $errors = array_merge($errors, $customfieldhandler->instance_form_validation($fromform, $files)); + return $errors; } diff --git a/question/type/questionbase.php b/question/type/questionbase.php index d045f868059..6df0ae5debc 100644 --- a/question/type/questionbase.php +++ b/question/type/questionbase.php @@ -124,6 +124,11 @@ abstract class question_definition { /** @var array of question_hints. */ public $hints = array(); + /** + * @var array of array of \core_customfield\data_controller objects indexed by fieldid for the questions custom fields. + */ + public $customfields = array(); + /** * Constructor. Normally to get a question, you call * {@link question_bank::load_question()}, but questions can be created diff --git a/question/type/questiontypebase.php b/question/type/questiontypebase.php index dad811cc97d..83d7f4fc7c6 100644 --- a/question/type/questiontypebase.php +++ b/question/type/questiontypebase.php @@ -964,6 +964,9 @@ class question_type { } $this->initialise_question_hints($question, $questiondata); + + // Add the custom fields. + $this->initialise_custom_fields($question, $questiondata); } /** @@ -990,6 +993,17 @@ class question_type { return question_hint::load_from_record($hint); } + /** + * Initialise question custom fields. + * @param question_definition $question the question_definition we are creating. + * @param object $questiondata the question data loaded from the database. + */ + protected function initialise_custom_fields(question_definition $question, $questiondata) { + if (!empty($questiondata->customfields)) { + $question->customfields = $questiondata->customfields; + } + } + /** * Initialise the combined feedback fields. * @param question_definition $question the question_definition we are creating.