MDL-66135 tool_uploadcourse: support custom course fields.

This commit is contained in:
Paul Holden 2019-10-01 15:16:13 +01:00
parent adca2ab629
commit d62fc08ed9
10 changed files with 512 additions and 9 deletions

View file

@ -284,6 +284,15 @@ class tool_uploadcourse_course {
return $this->errors;
}
/**
* Return array of valid fields for default values
*
* @return array
*/
protected function get_valid_fields() {
return array_merge(self::$validfields, \tool_uploadcourse_helper::get_custom_course_field_names());
}
/**
* Assemble the course data based on defaults.
*
@ -293,7 +302,7 @@ class tool_uploadcourse_course {
* @return array
*/
protected function get_final_create_data($data) {
foreach (self::$validfields as $field) {
foreach ($this->get_valid_fields() as $field) {
if (!isset($data[$field]) && isset($this->defaults[$field])) {
$data[$field] = $this->defaults[$field];
}
@ -316,9 +325,9 @@ class tool_uploadcourse_course {
global $DB;
$newdata = array();
$existingdata = $DB->get_record('course', array('shortname' => $this->shortname));
foreach (self::$validfields as $field) {
foreach ($this->get_valid_fields() as $field) {
if ($missingonly) {
if (!is_null($existingdata->$field) and $existingdata->$field !== '') {
if (isset($existingdata->$field) and $existingdata->$field !== '') {
continue;
}
}
@ -699,6 +708,27 @@ class tool_uploadcourse_course {
$coursedata[$rolekey] = $rolename;
}
// Custom fields. If the course already exists and mode isn't set to force creation, we can use its context.
if ($exists && $mode !== tool_uploadcourse_processor::MODE_CREATE_ALL) {
$context = context_course::instance($coursedata['id']);
} else {
// The category ID is taken from the defaults if it exists, otherwise from course data.
$context = context_coursecat::instance($this->defaults['category'] ?? $coursedata['category']);
}
$customfielddata = tool_uploadcourse_helper::get_custom_course_field_data($this->rawdata, $this->defaults, $context,
$errors);
if (!empty($errors)) {
foreach ($errors as $key => $message) {
$this->error($key, $message);
}
return false;
}
foreach ($customfielddata as $name => $value) {
$coursedata[$name] = $value;
}
// Some validation.
if (!empty($coursedata['format']) && !in_array($coursedata['format'], tool_uploadcourse_helper::get_course_formats())) {
$this->error('invalidcourseformat', new lang_string('invalidcourseformat', 'tool_uploadcourse'));

View file

@ -337,6 +337,103 @@ class tool_uploadcourse_helper {
return $rolenames;
}
/**
* Return array of all custom course fields indexed by their shortname
*
* @return \core_customfield\field_controller[]
*/
public static function get_custom_course_fields(): array {
$result = [];
$fields = \core_course\customfield\course_handler::create()->get_fields();
foreach ($fields as $field) {
$result[$field->get('shortname')] = $field;
}
return $result;
}
/**
* Return array of custom field element names
*
* @return string[]
*/
public static function get_custom_course_field_names(): array {
$result = [];
$fields = self::get_custom_course_fields();
foreach ($fields as $field) {
$controller = \core_customfield\data_controller::create(0, null, $field);
$result[] = $controller->get_form_element_name();
}
return $result;
}
/**
* Return any elements from passed $data whose key matches one of the custom course fields defined for the site
*
* @param array $data
* @param array $defaults
* @param context $context
* @param array $errors Will be populated with any errors
* @return array
*/
public static function get_custom_course_field_data(array $data, array $defaults, context $context,
array &$errors = []): array {
$fields = self::get_custom_course_fields();
$result = [];
$canchangelockedfields = guess_if_creator_will_have_course_capability('moodle/course:changelockedcustomfields', $context);
foreach ($data as $name => $originalvalue) {
if (preg_match('/^customfield_(?<name>.*)?$/', $name, $matches)
&& isset($fields[$matches['name']])) {
$fieldname = $matches['name'];
$field = $fields[$fieldname];
// Skip field if it's locked and user doesn't have capability to change locked fields.
if ($field->get_configdata_property('locked') && !$canchangelockedfields) {
continue;
}
// Create field data controller.
$controller = \core_customfield\data_controller::create(0, null, $field);
$controller->set('id', 1);
$defaultvalue = $defaults["customfield_{$fieldname}"] ?? $controller->get_default_value();
$value = (empty($originalvalue) ? $defaultvalue : $field->parse_value($originalvalue));
// If we initially had a value, but now don't, then reset it to the default.
if (!empty($originalvalue) && empty($value)) {
$value = $defaultvalue;
}
// Validate data with controller.
$fieldformdata = [$controller->get_form_element_name() => $value];
$validationerrors = $controller->instance_form_validation($fieldformdata, []);
if (count($validationerrors) > 0) {
$errors['customfieldinvalid'] = new lang_string('customfieldinvalid', 'tool_uploadcourse',
$field->get_formatted_name());
continue;
}
$controller->set($controller->datafield(), $value);
// Pass an empty object to the data controller, which will transform it to a correct name/value pair.
$instance = new stdClass();
$controller->instance_form_before_set_data($instance);
$result = array_merge($result, (array) $instance);
}
}
return $result;
}
/**
* Helper to increment an ID number.
*
@ -493,5 +590,4 @@ class tool_uploadcourse_helper {
}
return $id;
}
}
}

View file

@ -173,6 +173,10 @@ class tool_uploadcourse_step2_form extends tool_uploadcourse_base_form {
$mform->addHelpButton('defaults[enablecompletion]', 'enablecompletion', 'completion');
}
// Add custom fields to the form.
$handler = \core_course\customfield\course_handler::create();
$handler->instance_form_definition($mform, 0, 'defaultvaluescustomfieldcategory', 'tool_uploadcourse');
// Hidden fields.
$mform->addElement('hidden', 'importid');
$mform->setType('importid', PARAM_INT);
@ -182,6 +186,10 @@ class tool_uploadcourse_step2_form extends tool_uploadcourse_base_form {
$this->add_action_buttons(true, get_string('uploadcourses', 'tool_uploadcourse'));
// Prepare custom fields data.
$data = (object) $data;
$handler->instance_form_before_set_data($data);
$this->set_data($data);
}
@ -219,6 +227,9 @@ class tool_uploadcourse_step2_form extends tool_uploadcourse_base_form {
$enddate = $format->get_default_course_enddate($mform, array('startdate' => 'defaults[startdate]'));
$mform->setDefault('defaults[enddate]', $enddate);
}
// Tweak the form with values provided by custom fields in use.
\core_course\customfield\course_handler::create()->instance_form_definition_after_data($mform);
}
/**
@ -237,6 +248,9 @@ class tool_uploadcourse_step2_form extends tool_uploadcourse_base_form {
$errors['defaults[enddate]'] = get_string($errorcode, 'error');
}
// Custom fields validation.
array_merge($errors, \core_course\customfield\course_handler::create()->instance_form_validation($data, $files));
return $errors;
}
}

View file

@ -78,6 +78,12 @@ if ($form2data = $mform2->is_cancelled()) {
$options = (array) $form2data->options;
$defaults = (array) $form2data->defaults;
// Custom field defaults.
$customfields = tool_uploadcourse_helper::get_custom_course_field_names();
foreach ($customfields as $customfield) {
$defaults[$customfield] = $form2data->{$customfield};
}
// Restorefile deserves its own logic because formslib does not really appreciate
// when the name of a filepicker is an array...
$options['restorefile'] = '';

View file

@ -75,6 +75,7 @@ $string['csvdelimiter_help'] = 'CSV delimiter of the CSV file.';
$string['csvfileerror'] = 'There is something wrong with the format of the CSV file. Please check the number of headings and columns match, and that the delimiter and file encoding are correct: {$a}';
$string['csvline'] = 'Line';
$string['defaultvalues'] = 'Default course values';
$string['defaultvaluescustomfieldcategory'] = 'Default values for \'{$a}\'';
$string['encoding'] = 'Encoding';
$string['encoding_help'] = 'Encoding of the CSV file.';
$string['errorwhilerestoringcourse'] = 'Error while restoring the course';
@ -102,6 +103,7 @@ $string['mode_help'] = 'This allows you to specify if courses can be created and
$string['nochanges'] = 'No changes';
$string['pluginname'] = 'Course upload';
$string['preview'] = 'Preview';
$string['customfieldinvalid'] = 'Custom field \'{$a}\' is empty or contains invalid data';
$string['reset'] = 'Reset course after upload';
$string['reset_help'] = 'Whether to reset the course after creating/updating it.';
$string['result'] = 'Result';

View file

@ -42,3 +42,66 @@ Feature: An admin can create courses using a CSV file
And I should see "Course 1"
And I should see "Course 2"
And I should see "Course 3"
@javascript
Scenario: Creation of new courses with custom fields
Given the following "custom field categories" exist:
| name | component | area | itemid |
| Other | core_course | course | 0 |
And the following "custom fields" exist:
| name | category | type | shortname | configdata |
| Field 1 | Other | checkbox | checkbox | |
| Field 2 | Other | date | date | |
| Field 3 | Other | select | select | {"options":"a\nb\nc"} |
| Field 4 | Other | text | text | |
| Field 5 | Other | textarea | textarea | |
When I upload "admin/tool/uploadcourse/tests/fixtures/courses_custom_fields.csv" file to "File" filemanager
And I set the field "Upload mode" to "Create new courses only, skip existing ones"
And I click on "Preview" "button"
And I click on "Upload courses" "button"
Then I should see "Course created"
And I should see "Courses created: 1"
And I am on site homepage
And I should see "Course fields 1"
And I should see "Field 1: Yes"
And I should see "Field 2: Tuesday, 1 October 2019, 2:00"
And I should see "Field 3: b"
And I should see "Field 4: Hello"
And I should see "Field 5: Goodbye"
@javascript
Scenario: Creation of new courses with custom fields using defaults
Given the following "custom field categories" exist:
| name | component | area | itemid |
| Other | core_course | course | 0 |
And the following "custom fields" exist:
| name | category | type | shortname | configdata |
| Field 1 | Other | checkbox | checkbox | {"checkbydefault":1} |
| Field 2 | Other | date | date | {"includetime":0} |
| Field 3 | Other | select | select | {"options":"a\nb\nc","defaultvalue":"b"} |
| Field 4 | Other | text | text | {"defaultvalue":"Hello"} |
| Field 5 | Other | textarea | textarea | {"defaultvalue":"Some text","defaultvalueformat":1} |
When I upload "admin/tool/uploadcourse/tests/fixtures/courses.csv" file to "File" filemanager
And I set the field "Upload mode" to "Create all, increment shortname if needed"
And I click on "Preview" "button"
And I expand all fieldsets
And the field "Field 1" matches value "1"
And the field "Field 3" matches value "b"
And the field "Field 4" matches value "Hello"
And the field "Field 5" matches value "Some text"
# We have to enable the date field manually.
And I set the following fields to these values:
| customfield_date[enabled] | 1 |
| customfield_date[day] | 1 |
| customfield_date[month] | June |
| customfield_date[year] | 2020 |
And I click on "Upload courses" "button"
Then I should see "Course created"
And I should see "Courses created: 3"
And I am on site homepage
And I should see "Course 1"
And I should see "Field 1: Yes"
And I should see "Field 2: 1 June 2020"
And I should see "Field 3: b"
And I should see "Field 4: Hello"
And I should see "Field 5: Some text"

View file

@ -7,7 +7,8 @@ Feature: An admin can update courses using a CSV file
Background:
Given the following "courses" exist:
| fullname | shortname | category |
| Some random name | C1 | 0 |
| Some random name | C1 | 0 |
| Another course | CF1 | 0 |
And I log in as "admin"
And I navigate to "Courses > Upload courses" in site administration
@ -28,3 +29,31 @@ Feature: An admin can update courses using a CSV file
And I should see "Course 1"
And I should not see "Course 2"
And I should not see "Course 3"
@javascript
Scenario: Updating a course with custom fields
Given the following "custom field categories" exist:
| name | component | area | itemid |
| Other | core_course | course | 0 |
And the following "custom fields" exist:
| name | category | type | shortname | configdata |
| Field 1 | Other | checkbox | checkbox | |
| Field 2 | Other | date | date | |
| Field 3 | Other | select | select | {"options":"a\nb\nc"} |
| Field 4 | Other | text | text | |
| Field 5 | Other | textarea | textarea | |
When I upload "admin/tool/uploadcourse/tests/fixtures/courses_custom_fields.csv" file to "File" filemanager
And I set the following fields to these values:
| Upload mode | Only update existing courses |
| Update mode | Update with CSV data only |
And I click on "Preview" "button"
And I click on "Upload courses" "button"
Then I should see "Course updated"
And I should see "Courses updated: 1"
And I am on site homepage
And I should see "Course fields 1"
And I should see "Field 1: Yes"
And I should see "Field 2: Tuesday, 1 October 2019, 2:00"
And I should see "Field 3: b"
And I should see "Field 4: Hello"
And I should see "Field 5: Goodbye"

View file

@ -1081,6 +1081,136 @@ class tool_uploadcourse_course_testcase extends advanced_testcase {
$this->assertEquals(strtotime('12th July 2013'), $enroldata['manual']->enrolenddate);
}
/**
* Test upload processing of course custom fields
*/
public function test_custom_fields_data() {
$this->resetAfterTest();
$this->setAdminUser();
$course = $this->getDataGenerator()->create_course(['shortname' => 'C1']);
// Create our custom fields.
$category = $this->get_customfield_generator()->create_category();
$this->create_custom_field($category, 'date', 'mydatefield');
$this->create_custom_field($category, 'text', 'mytextfield');
$this->create_custom_field($category, 'textarea', 'mytextareafield');
// Perform upload.
$mode = tool_uploadcourse_processor::MODE_UPDATE_ONLY;
$updatemode = tool_uploadcourse_processor::UPDATE_ALL_WITH_DATA_ONLY;
$dataupload = [
'shortname' => $course->shortname,
'customfield_mydatefield' => '2020-04-01 16:00',
'customfield_mytextfield' => 'Hello',
'customfield_mytextareafield' => 'Is it me you\'re looking for?',
];
$uploader = new tool_uploadcourse_course($mode, $updatemode, $dataupload);
$this->assertTrue($uploader->prepare());
$uploader->proceed();
// Confirm presence of course custom fields.
$data = \core_course\customfield\course_handler::create()->export_instance_data_object($course->id);
$this->assertEquals('Wednesday, 1 April 2020, 4:00 PM', $data->mydatefield);
$this->assertEquals($dataupload['customfield_mytextfield'], $data->mytextfield);
$this->assertContains($dataupload['customfield_mytextareafield'], $data->mytextareafield);
}
/**
* Test upload processing of course custom field that is required but empty
*/
public function test_custom_fields_data_required() {
$this->resetAfterTest();
$this->setAdminUser();
$course = $this->getDataGenerator()->create_course(['shortname' => 'C1']);
// Create our custom field.
$category = $this->get_customfield_generator()->create_category();
$this->create_custom_field($category, 'select', 'myselect', ['required' => true, 'options' => "Cat\nDog"]);
$mode = tool_uploadcourse_processor::MODE_UPDATE_ONLY;
$updatemode = tool_uploadcourse_processor::UPDATE_ALL_WITH_DATA_ONLY;
$dataupload = [
'shortname' => $course->shortname,
'customfield_myselect' => null,
];
$uploader = new tool_uploadcourse_course($mode, $updatemode, $dataupload);
$this->assertFalse($uploader->prepare());
$this->assertArrayHasKey('customfieldinvalid', $uploader->get_errors());
// Try again with a default value.
$defaults = [
'customfield_myselect' => 2, // Our second option: Dog.
];
$uploader = new tool_uploadcourse_course($mode, $updatemode, $dataupload, $defaults);
$this->assertTrue($uploader->prepare());
$uploader->proceed();
// Confirm presence of course custom fields.
$data = \core_course\customfield\course_handler::create()->export_instance_data_object($course->id);
$this->assertEquals('Dog', $data->myselect);
}
/**
* Test upload processing of course custom field with an invalid select option
*/
public function test_custom_fields_data_invalid_select_option() {
$this->resetAfterTest();
$this->setAdminUser();
$course = $this->getDataGenerator()->create_course(['shortname' => 'C1']);
// Create our custom field.
$category = $this->get_customfield_generator()->create_category();
$this->create_custom_field($category, 'select', 'myselect',
['required' => true, 'options' => "Cat\nDog", 'defaultvalue' => 'Cat']);
$mode = tool_uploadcourse_processor::MODE_UPDATE_ONLY;
$updatemode = tool_uploadcourse_processor::UPDATE_ALL_WITH_DATA_ONLY;
$dataupload = [
'shortname' => $course->shortname,
'customfield_myselect' => 'Fish', // No, invalid.
];
$uploader = new tool_uploadcourse_course($mode, $updatemode, $dataupload);
$this->assertTrue($uploader->prepare());
$uploader->proceed();
// Confirm presence of course custom fields.
$data = \core_course\customfield\course_handler::create()->export_instance_data_object($course->id);
$this->assertEquals('Cat', $data->myselect);
}
/**
* Test upload processing of course custom field with an out of range date
*/
public function test_custom_fields_data_invalid_date() {
$this->resetAfterTest();
$this->setAdminUser();
$course = $this->getDataGenerator()->create_course(['shortname' => 'C1']);
// Create our custom field.
$category = $this->get_customfield_generator()->create_category();
$this->create_custom_field($category, 'date', 'mydate',
['mindate' => strtotime('2020-04-01'), 'maxdate' => '2020-04-30']);
$mode = tool_uploadcourse_processor::MODE_UPDATE_ONLY;
$updatemode = tool_uploadcourse_processor::UPDATE_ALL_WITH_DATA_ONLY;
$dataupload = [
'shortname' => $course->shortname,
'customfield_mydate' => '2020-05-06', // Out of range.
];
$uploader = new tool_uploadcourse_course($mode, $updatemode, $dataupload);
$this->assertFalse($uploader->prepare());
$this->assertArrayHasKey('customfieldinvalid', $uploader->get_errors());
}
public function test_idnumber_problems() {
$this->resetAfterTest(true);
@ -1224,7 +1354,34 @@ class tool_uploadcourse_course_testcase extends advanced_testcase {
$co = new tool_uploadcourse_course($mode, $updatemode, $data, array(), $importoptions);
$this->assertFalse($co->prepare());
$this->assertArrayHasKey('cannotrenameshortnamealreadyinuse', $co->get_errors());
}
}
/**
* Get custom field plugin generator
*
* @return core_customfield_generator
*/
protected function get_customfield_generator() : core_customfield_generator {
return $this->getDataGenerator()->get_plugin_generator('core_customfield');
}
/**
* Helper method to create custom course field
*
* @param \core_customfield\category_controller $category
* @param string $type
* @param string $shortname
* @param array $configdata
* @return \core_customfield\field_controller
*/
protected function create_custom_field(\core_customfield\category_controller $category, string $type, string $shortname,
array $configdata = []) : \core_customfield\field_controller {
return $this->get_customfield_generator()->create_field([
'categoryid' => $category->get('id'),
'type' => $type,
'shortname' => $shortname,
'configdata' => $configdata,
]);
}
}

View file

@ -0,0 +1,2 @@
shortname,fullname,summary,category,customfield_checkbox,customfield_date,customfield_select,customfield_text,customfield_textarea
CF1,Course fields 1,Testing course fields,1,1,2019-10-01 14:00,b,Hello,Goodbye
1 shortname fullname summary category customfield_checkbox customfield_date customfield_select customfield_text customfield_textarea
2 CF1 Course fields 1 Testing course fields 1 1 2019-10-01 14:00 b Hello Goodbye

View file

@ -250,6 +250,81 @@ class tool_uploadcourse_helper_testcase extends advanced_testcase {
$this->assertArrayHasKey('invalidroles', $errors);
}
/**
* Test custom field data processing
*/
public function test_get_custom_course_field_data() {
global $DB;
$this->resetAfterTest();
// Create all the fields!
$category = $this->get_customfield_generator()->create_category();
$checkboxfield = $this->create_custom_field($category, 'checkbox', 'mycheckbox');
$datefield = $this->create_custom_field($category, 'date', 'mydate');
$selectfield = $this->create_custom_field($category, 'select', 'myselect', ['options' => "Red\nGreen\nBlue"]);
$textfield = $this->create_custom_field($category, 'text', 'mytext', ['locked' => 1]);
$textareafield = $this->create_custom_field($category, 'textarea', 'mytextarea');
$fields = tool_uploadcourse_helper::get_custom_course_fields();
$this->assertCount(5, $fields);
$this->assertArrayHasKey($checkboxfield->get('shortname'), $fields);
$this->assertInstanceOf(customfield_checkbox\field_controller::class, $fields[$checkboxfield->get('shortname')]);
$this->assertArrayHasKey($datefield->get('shortname'), $fields);
$this->assertInstanceOf(customfield_date\field_controller::class, $fields[$datefield->get('shortname')]);
$this->assertArrayHasKey($selectfield->get('shortname'), $fields);
$this->assertInstanceOf(customfield_select\field_controller::class, $fields[$selectfield->get('shortname')]);
$this->assertArrayHasKey($textfield->get('shortname'), $fields);
$this->assertInstanceOf(customfield_text\field_controller::class, $fields[$textfield->get('shortname')]);
$this->assertArrayHasKey($textareafield->get('shortname'), $fields);
$this->assertInstanceOf(customfield_textarea\field_controller::class, $fields[$textareafield->get('shortname')]);
$data = [
'customfield_mycheckbox' => '1',
'customfield_mydate' => '2019-10-01',
'customfield_myselect' => 'Green',
'customfield_mytext' => 'Hello',
'customfield_myunknownfield' => 'Goodbye',
];
$expected = [
'customfield_mycheckbox' => '1',
'customfield_mydate' => strtotime('2019-10-01'),
'customfield_myselect' => 2,
'customfield_mytext' => 'Hello',
];
$course = $this->getDataGenerator()->create_course();
$user = $this->getDataGenerator()->create_and_enrol($course, 'manager');
$this->setUser($user);
$context = context_course::instance($course->id);
$this->assertEquals($expected, tool_uploadcourse_helper::get_custom_course_field_data($data, [], $context));
// Now add our custom textarea field (separately because the value of it's 'itemid' element is unknown).
$data['customfield_mytextarea'] = 'Something';
$fields = tool_uploadcourse_helper::get_custom_course_field_data($data, [], $context);
$this->assertArrayHasKey('customfield_mytextarea_editor', $fields);
$this->assertArrayHasKey('text', $fields['customfield_mytextarea_editor']);
$this->assertEquals('Something', $fields['customfield_mytextarea_editor']['text']);
// Now prohibit the capability to change locked fields for the manager role.
$managerrole = $DB->get_record('role', ['shortname' => 'manager']);
role_change_permission($managerrole->id, $context, 'moodle/course:changelockedcustomfields', CAP_PROHIBIT);
// The locked 'mytext' custom field should not be returned.
$fields = tool_uploadcourse_helper::get_custom_course_field_data($data, [], $context);
$this->assertCount(4, $fields);
$this->assertArrayNotHasKey('customfield_mytext', $fields);
}
public function test_increment_idnumber() {
$this->resetAfterTest(true);
@ -394,4 +469,33 @@ class tool_uploadcourse_helper_testcase extends advanced_testcase {
$this->assertEquals($cat3_fakedouble->id, tool_uploadcourse_helper::resolve_category_by_path($path));
$this->assertEquals($cat3_fakedouble->id, tool_uploadcourse_helper::resolve_category_by_path($path));
}
}
/**
* Get custom field plugin generator
*
* @return core_customfield_generator
*/
protected function get_customfield_generator() : core_customfield_generator {
return $this->getDataGenerator()->get_plugin_generator('core_customfield');
}
/**
* Helper method to create custom course field
*
* @param \core_customfield\category_controller $category
* @param string $type
* @param string $shortname
* @param array $configdata
* @return \core_customfield\field_controller
*/
protected function create_custom_field(\core_customfield\category_controller $category, string $type, string $shortname,
array $configdata = []) : \core_customfield\field_controller {
return $this->get_customfield_generator()->create_field([
'categoryid' => $category->get('id'),
'type' => $type,
'shortname' => $shortname,
'configdata' => $configdata,
]);
}
}