MDL-57898 core_course: add custom fields support to courses
authorDavid Matamoros <davidmc@moodle.com>
Fri, 11 Jan 2019 09:56:14 +0000 (10:56 +0100)
committerMarina Glancy <marina@moodle.com>
Fri, 18 Jan 2019 13:28:18 +0000 (14:28 +0100)
This commit is part of work on Custom fields API,
to minimize commit history in moodle core the work of a team of developers was split
into several commits with different authors but the authorship of individual
lines of code may be different from the commit author.

14 files changed:
admin/settings/courses.php
course/classes/customfield/course_handler.php [new file with mode: 0644]
course/customfield.php [new file with mode: 0644]
course/edit_form.php
course/externallib.php
course/lib.php
course/tests/behat/customfields_locked.feature [new file with mode: 0644]
course/tests/behat/customfields_visibility.feature [new file with mode: 0644]
course/tests/customfield_test.php [new file with mode: 0644]
course/tests/externallib_test.php
lang/en/course.php
lang/en/role.php
lib/db/access.php
lib/moodlelib.php

index f23bf5f..739c96e 100644 (file)
@@ -37,6 +37,12 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
             array('moodle/category:manage', 'moodle/course:create')
         )
     );
+    $ADMIN->add('courses',
+        new admin_externalpage('course_customfield', new lang_string('course_customfield', 'admin'),
+            $CFG->wwwroot . '/course/customfield.php',
+            array('moodle/course:configurecustomfields')
+        )
+    );
     $ADMIN->add('courses',
         new admin_externalpage('addcategory', new lang_string('addcategory', 'admin'),
             new moodle_url('/course/editcategory.php', array('parent' => 0)),
diff --git a/course/classes/customfield/course_handler.php b/course/classes/customfield/course_handler.php
new file mode 100644 (file)
index 0000000..cc94d2e
--- /dev/null
@@ -0,0 +1,242 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Course handler for custom fields
+ *
+ * @package   core_course
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_course\customfield;
+
+defined('MOODLE_INTERNAL') || die;
+
+use core_customfield\api;
+use core_customfield\data_controller;
+use core_customfield\field_controller;
+
+/**
+ * Course handler for custom fields
+ *
+ * @package core_course
+ * @copyright 2018 David Matamoros <davidmc@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class course_handler extends \core_customfield\handler {
+
+    /**
+     * @var course_handler
+     */
+    static protected $singleton;
+
+    /**
+     * @var \context
+     */
+    protected $parentcontext;
+
+    /** @var int */
+    const VISIBLETOALL = 2;
+    /** @var int */
+    const VISIBLETOTEACHERS = 1;
+    /** @var int */
+    const NOTVISIBLE = 0;
+
+    /**
+     * Returns a singleton
+     *
+     * @param int $itemid
+     * @return \core_course\customfield\course_handler
+     */
+    public static function create(int $itemid = 0) : \core_customfield\handler {
+        if (static::$singleton === null) {
+            self::$singleton = new static(0);
+        }
+        return self::$singleton;
+    }
+
+    /**
+     * 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('moodle/course:configurecustomfields', $this->get_configuration_context());
+    }
+
+    /**
+     * The current user can edit custom fields on the given course.
+     *
+     * @param field_controller $field
+     * @param int $instanceid id of the course 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);
+            return (!$field->get_configdata_property('locked') ||
+                    has_capability('moodle/course:changelockedcustomfields', $context));
+        } else {
+            $context = $this->get_parent_context();
+            return (!$field->get_configdata_property('locked') ||
+                guess_if_creator_will_have_course_capability('moodle/course:changelockedcustomfields', $context));
+        }
+    }
+
+    /**
+     * The current user can view custom fields on the given course.
+     *
+     * @param field_controller $field
+     * @param int $instanceid id of the course 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('moodle/course:update', $this->get_instance_context($instanceid));
+        } else {
+            return true;
+        }
+    }
+
+    /**
+     * Sets parent context for the course
+     *
+     * This may be needed when course is being created, there is no course context but we need to check capabilities
+     *
+     * @param \context $context
+     */
+    public function set_parent_context(\context $context) {
+        $this->parentcontext = $context;
+    }
+
+    /**
+     * Returns the parent context for the course
+     *
+     * @return \context
+     */
+    protected function get_parent_context() : \context {
+        global $PAGE;
+        if ($this->parentcontext) {
+            return $this->parentcontext;
+        } else if ($PAGE->context && $PAGE->context instanceof \context_coursecat) {
+            return $PAGE->context;
+        }
+        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 of the fields on this handler.
+     *
+     * @return \moodle_url The URL to configure custom fields for this component
+     */
+    public function get_configuration_url(): \moodle_url {
+        return new \moodle_url('/course/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
+     */
+    public function get_instance_context(int $instanceid = 0): \context {
+        if ($instanceid > 0) {
+            return \context_course::instance($instanceid);
+        } else {
+            return \context_system::instance();
+        }
+    }
+
+    /**
+     * Allows to add custom controls to the field configuration form that will be saved in configdata
+     *
+     * @param \MoodleQuickForm $mform
+     */
+    public function config_form_definition(\MoodleQuickForm $mform) {
+        $mform->addElement('header', 'course_handler_header', get_string('customfieldsettings', 'core_course'));
+        $mform->setExpanded('course_handler_header', true);
+
+        // If field is locked.
+        $mform->addElement('selectyesno', 'configdata[locked]', get_string('customfield_islocked', 'core_course'));
+        $mform->addHelpButton('configdata[locked]', 'customfield_islocked', 'core_course');
+
+        // Field data visibility.
+        $visibilityoptions = [self::VISIBLETOALL => get_string('customfield_visibletoall', 'core_course'),
+            self::VISIBLETOTEACHERS => get_string('customfield_visibletoteachers', 'core_course'),
+            self::NOTVISIBLE => get_string('customfield_notvisible', 'core_course')];
+        $mform->addElement('select', 'configdata[visibility]', get_string('customfield_visibility', 'core_course'),
+            $visibilityoptions);
+        $mform->addHelpButton('configdata[visibility]', 'customfield_visibility', 'core_course');
+    }
+
+    /**
+     * Creates or updates custom field data.
+     *
+     * @param \restore_task $task
+     * @param array $data
+     */
+    public function restore_instance_data_from_backup(\restore_task $task, array $data) {
+        $courseid = $task->get_courseid();
+        $context = $this->get_instance_context($courseid);
+        $editablefields = $this->get_editable_fields($courseid);
+        $records = api::get_instance_fields_data($editablefields, $courseid);
+        $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', $context->id);
+                    $d->save();
+                }
+                return;
+            }
+        }
+    }
+
+    /**
+     * Set up page customfield/edit.php
+     *
+     * @param field_controller $field
+     * @return string page heading
+     */
+    public function setup_edit_page(field_controller $field): string {
+        global $CFG, $PAGE;
+        require_once($CFG->libdir.'/adminlib.php');
+
+        $title = parent::setup_edit_page($field);
+        admin_externalpage_setup('course_customfield');
+        $PAGE->navbar->add($title);
+        return $title;
+    }
+}
diff --git a/course/customfield.php b/course/customfield.php
new file mode 100644 (file)
index 0000000..2e92ec8
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Manage course custom fields
+ *
+ * @package core_course
+ * @copyright 2018 Toni Barbera (toni@moodle.com)
+ * @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('course_customfield');
+
+$output = $PAGE->get_renderer('core_customfield');
+$handler = core_course\customfield\course_handler::create();
+$outputpage = new \core_customfield\output\management($handler);
+
+echo $output->header(),
+     $output->heading(new lang_string('course_customfield', 'admin')),
+     $output->render($outputpage),
+     $output->footer();
index a91cb5f..1f42e6c 100644 (file)
@@ -320,6 +320,11 @@ class course_edit_form extends moodleform {
                     array('itemtype' => 'course', 'component' => 'core'));
         }
 
+        // Add custom fields to the form.
+        $handler = core_course\customfield\course_handler::create();
+        $handler->set_parent_context($categorycontext); // For course handler only.
+        $handler->instance_form_definition($mform, empty($course->id) ? 0 : $course->id);
+
         // When two elements we need a group.
         $buttonarray = array();
         $classarray = array('class' => 'form-submit');
@@ -334,6 +339,8 @@ class course_edit_form extends moodleform {
         $mform->addElement('hidden', 'id', null);
         $mform->setType('id', PARAM_INT);
 
+        // Prepare custom fields data.
+        $handler->instance_form_before_set_data($course);
         // Finally set the current form data
         $this->set_data($course);
     }
@@ -383,6 +390,10 @@ class course_edit_form extends moodleform {
                 $mform->removeElement('newsitems');
             }
         }
+
+        // Tweak the form with values provided by custom fields in use.
+        $handler  = core_course\customfield\course_handler::create();
+        $handler->instance_form_definition_after_data($mform, empty($courseid) ? 0 : $courseid);
     }
 
     /**
@@ -425,7 +436,10 @@ class course_edit_form extends moodleform {
             $errors = array_merge($errors, $formaterrors);
         }
 
+        // Add the custom fields validation.
+        $handler = core_course\customfield\course_handler::create();
+        $errors  = array_merge($errors, $handler->instance_form_validation($data, $files));
+
         return $errors;
     }
 }
-
index 81e21ab..0eb164c 100644 (file)
@@ -553,6 +553,19 @@ class core_course_external extends external_api {
                 $courseinfo['numsections'] = $courseformatoptions['numsections'];
             }
 
+            $handler = core_course\customfield\course_handler::create();
+            if ($customfields = $handler->export_instance_data($course->id)) {
+                $courseinfo['customfields'] = [];
+                foreach ($customfields as $data) {
+                    $courseinfo['customfields'][] = [
+                        'type' => $data->get_type(),
+                        'value' => $data->get_value(),
+                        'name' => $data->get_name(),
+                        'shortname' => $data->get_shortname()
+                    ];
+                }
+            }
+
             //some field should be returned only if the user has update permission
             $courseadmin = has_capability('moodle/course:update', $context);
             if ($courseadmin) {
@@ -663,9 +676,15 @@ class core_course_external extends external_api {
                                 new external_single_structure(
                                     array('name' => new external_value(PARAM_ALPHANUMEXT, 'course format option name'),
                                         'value' => new external_value(PARAM_RAW, 'course format option value')
-                                )),
-                                    'additional options for particular course format', VALUE_OPTIONAL
+                                )), 'additional options for particular course format', VALUE_OPTIONAL
                              ),
+                            'customfields' => new external_multiple_structure(
+                                new external_single_structure(
+                                    ['name' => new external_value(PARAM_TEXT, 'The name of the custom field'),
+                                     'shortname' => new external_value(PARAM_ALPHANUMEXT, 'The shortname of the custom field'),
+                                     'type'  => new external_value(PARAM_COMPONENT, 'The type of the custom field - text, checkbox...'),
+                                     'value' => new external_value(PARAM_RAW, 'The value of the custom field')]
+                                ), 'Custom fields and associated values', VALUE_OPTIONAL),
                         ), 'course'
                 )
         );
@@ -739,8 +758,14 @@ class core_course_external extends external_api {
                                         'value' => new external_value(PARAM_RAW, 'course format option value')
                                 )),
                                     'additional options for particular course format', VALUE_OPTIONAL),
-                        )
-                    ), 'courses to create'
+                            'customfields' => new external_multiple_structure(
+                                new external_single_structure(
+                                    array(
+                                        'shortname'  => new external_value(PARAM_ALPHANUMEXT, 'The shortname of the custom field'),
+                                        'value' => new external_value(PARAM_RAW, 'The value of the custom field'),
+                                )), 'custom fields for the course', VALUE_OPTIONAL
+                            )
+                    )), 'courses to create'
                 )
             )
         );
@@ -828,6 +853,13 @@ class core_course_external extends external_api {
                 }
             }
 
+            // Custom fields.
+            if (!empty($course['customfields'])) {
+                foreach ($course['customfields'] as $field) {
+                    $course['customfield_'.$field['shortname']] = $field['value'];
+                }
+            }
+
             //Note: create_course() core function check shortname, idnumber, category
             $course['id'] = create_course((object) $course)->id;
 
@@ -909,8 +941,14 @@ class core_course_external extends external_api {
                                 new external_single_structure(
                                     array('name' => new external_value(PARAM_ALPHANUMEXT, 'course format option name'),
                                         'value' => new external_value(PARAM_RAW, 'course format option value')
-                                )),
-                                    'additional options for particular course format', VALUE_OPTIONAL),
+                                )), 'additional options for particular course format', VALUE_OPTIONAL),
+                            'customfields' => new external_multiple_structure(
+                                new external_single_structure(
+                                    [
+                                        'shortname'  => new external_value(PARAM_ALPHANUMEXT, 'The shortname of the custom field'),
+                                        'value' => new external_value(PARAM_RAW, 'The value of the custom field')
+                                    ]
+                                ), 'Custom fields', VALUE_OPTIONAL),
                         )
                     ), 'courses to update'
                 )
@@ -1024,6 +1062,13 @@ class core_course_external extends external_api {
                     }
                 }
 
+                // Prepare list of custom fields.
+                if (isset($course['customfields'])) {
+                    foreach ($course['customfields'] as $field) {
+                        $course['customfield_' . $field['shortname']] = $field['value'];
+                    }
+                }
+
                 // Update course if user has all required capabilities.
                 update_course((object) $course);
             } catch (Exception $e) {
@@ -2499,6 +2544,15 @@ class core_course_external extends external_api {
                 new external_value(PARAM_PLUGIN, 'enrollment method'),
                 'enrollment methods list'
             ),
+            'customfields' => new external_multiple_structure(
+                new external_single_structure(
+                    array(
+                        'name' => new external_value(PARAM_RAW, 'The name of the custom field'),
+                        'shortname' => new external_value(PARAM_RAW, 'The shortname of the custom field - to be able to build the field class in the code'),
+                        'type'  => new external_value(PARAM_ALPHANUMEXT, 'The type of the custom field - text field, checkbox...'),
+                        'value' => new external_value(PARAM_RAW, 'The value of the custom field'),
+                    )
+                ), 'Custom fields', VALUE_OPTIONAL),
         );
 
         if (!$onlypublicdata) {
index 5f13036..da2524a 100644 (file)
@@ -2488,6 +2488,11 @@ function create_course($data, $editoroptions = NULL) {
         core_tag_tag::set_item_tags('core', 'course', $course->id, context_course::instance($course->id), $data->tags);
     }
 
+    // Save custom fields if there are any of them in the form.
+    $handler = core_course\customfield\course_handler::create();
+    $data->id = $course->id;
+    $handler->instance_form_save($data, true);
+
     return $course;
 }
 
@@ -2572,6 +2577,10 @@ function update_course($data, $editoroptions = NULL) {
         }
     }
 
+    // Update custom fields if there are any of them in the form.
+    $handler = core_course\customfield\course_handler::create();
+    $handler->instance_form_save($data);
+
     // Update with the new data
     $DB->update_record('course', $data);
     // make sure the modinfo cache is reset
diff --git a/course/tests/behat/customfields_locked.feature b/course/tests/behat/customfields_locked.feature
new file mode 100644 (file)
index 0000000..a3c73b6
--- /dev/null
@@ -0,0 +1,30 @@
+@core @core_course @core_customfield
+Feature: Fields locked control where they are displayed
+  In order to display custom fields on course listing
+  As a manager
+  I can change the visibility of the fields
+
+  Background:
+    Given the following "custom field categories" exist:
+      | name              | component   | area   | itemid |
+      | Category for test | core_course | course | 0      |
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student@example.com  |
+    And the following "courses" exist:
+      | fullname | shortname | format |
+      | Course 1 | C1        | topics |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+
+  Scenario: Display course custom fields on homepage
+    When I log in as "admin"
+    And I navigate to "Courses > Course custom fields" in site administration
+    And I click on "Add a new custom field" "link"
+    And I click on "Text field" "link"
+    And I set the following fields to these values:
+      | Name       | Test field |
+      | Short name | testfield  |
+      | Locked     | No         |
diff --git a/course/tests/behat/customfields_visibility.feature b/course/tests/behat/customfields_visibility.feature
new file mode 100644 (file)
index 0000000..bc32f62
--- /dev/null
@@ -0,0 +1,84 @@
+@core @core_course @core_customfield
+Feature: The visibility of fields control where they are displayed
+  In order to display custom fields on course listing
+  As a manager
+  I can change the visibility of the fields
+
+  Background:
+    Given the following "custom field categories" exist:
+      | name              | component   | area   | itemid |
+      | Category for test | core_course | course | 0      |
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student@example.com  |
+    And the following "courses" exist:
+      | fullname | shortname | format |
+      | Course 1 | C1        | topics |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+
+  Scenario: Display course custom fields on homepage
+    When I log in as "admin"
+    And I navigate to "Courses > Course custom fields" in site administration
+    And I click on "Add a new custom field" "link"
+    And I click on "Text field" "link"
+    And I set the following fields to these values:
+      | Name       | Test field |
+      | Short name | testfield  |
+      | Visible to | Everyone   |
+    And I press "Save changes"
+    And I log out
+    Then I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Test field | testcontent |
+    And I press "Save and display"
+    And I am on site homepage
+    Then I should see "Test field: testcontent"
+
+  Scenario: Do not display course custom fields on homepage
+    When I log in as "admin"
+    And I navigate to "Courses > Course custom fields" in site administration
+    And I click on "Add a new custom field" "link"
+    And I click on "Text field" "link"
+    And I set the following fields to these values:
+      | Name       | Test field  |
+      | Short name | testfield   |
+      | Visible to | Not visible |
+    And I press "Save changes"
+    And I log out
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Test field | testcontent |
+    And I press "Save and display"
+    And I am on site homepage
+    And I should not see "Test field: testcontent"
+
+  Scenario: Display course custom fields on homepage only to course editors
+    When I log in as "admin"
+    And I navigate to "Courses > Course custom fields" in site administration
+    And I click on "Add a new custom field" "link"
+    And I click on "Text field" "link"
+    And I set the following fields to these values:
+      | Name       | Test field     |
+      | Short name | testfield      |
+      | Visible to | Course editors |
+    And I press "Save changes"
+    And I log out
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Test field | testcontent |
+    And I press "Save and display"
+    When I am on site homepage
+    And I should see "Test field: testcontent"
+    And I log out
+    When I log in as "student"
+    When I am on site homepage
+    And I should not see "Test field: testcontent"
diff --git a/course/tests/customfield_test.php b/course/tests/customfield_test.php
new file mode 100644 (file)
index 0000000..2673208
--- /dev/null
@@ -0,0 +1,191 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Tests for customfields in courses
+ *
+ * @package    core_course
+ * @copyright  2018 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+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');
+
+/**
+ * Tests for customfields in courses
+ *
+ * @package    core_course
+ * @copyright  2018 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_course_customfield_testcase extends advanced_testcase {
+
+    /**
+     * Set up
+     */
+    protected function setUp() {
+        parent::setUp();
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $dg = self::getDataGenerator();
+        $catid = $dg->create_custom_field_category([])->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']);
+    }
+
+    /**
+     * Test creating course with customfields and retrieving them
+     */
+    public function test_create_course() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+
+        $now = time();
+        $c1 = $dg->create_course(['shortname' => 'SN', 'fullname' => 'FN',
+            'summary' => 'DESC', 'summaryformat' => FORMAT_MOODLE,
+            'customfield_f1' => 'some text', 'customfield_f2' => 1,
+            'customfield_f3' => $now, 'customfield_f4' => 2,
+            'customfield_f5_editor' => ['text' => 'test', 'format' => FORMAT_HTML]]);
+
+        $data = \core_course\customfield\course_handler::create()->export_instance_data_object($c1->id);
+
+        $this->assertEquals('some text', $data->f1);
+        $this->assertEquals('Yes', $data->f2);
+        $this->assertEquals(userdate($now, get_string('strftimedaydatetime')), $data->f3);
+        $this->assertEquals('b', $data->f4);
+        $this->assertEquals('test', $data->f5);
+
+        $this->assertEquals(5, count($DB->get_records('customfield_data')));
+
+        delete_course($c1->id, false);
+
+        $this->assertEquals(0, count($DB->get_records('customfield_data')));
+    }
+
+    /**
+     * Backup a course and return its backup ID.
+     *
+     * @param int $courseid The course ID.
+     * @param int $userid The user doing the backup.
+     * @return string
+     */
+    protected function backup_course($courseid, $userid = 2) {
+        $backuptempdir = make_backup_temp_directory('');
+        $packer = get_file_packer('application/vnd.moodle.backup');
+
+        $bc = new backup_controller(backup::TYPE_1COURSE, $courseid, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO,
+            backup::MODE_GENERAL, $userid);
+        $bc->execute_plan();
+
+        $results = $bc->get_results();
+        $results['backup_destination']->extract_to_pathname($packer, "$backuptempdir/core_course_testcase");
+
+        $bc->destroy();
+        unset($bc);
+        return 'core_course_testcase';
+    }
+
+    /**
+     * Restore a course.
+     *
+     * @param int $backupid The backup ID.
+     * @param int $courseid The course ID to restore in, or 0.
+     * @param int $userid The ID of the user performing the restore.
+     * @return stdClass The updated course object.
+     */
+    protected function restore_course($backupid, $courseid, $userid) {
+        global $DB;
+
+        $target = backup::TARGET_CURRENT_ADDING;
+        if (!$courseid) {
+            $target = backup::TARGET_NEW_COURSE;
+            $categoryid = $DB->get_field_sql("SELECT MIN(id) FROM {course_categories}");
+            $courseid = restore_dbops::create_new_course('Tmp', 'tmp', $categoryid);
+        }
+
+        $rc = new restore_controller($backupid, $courseid, backup::INTERACTIVE_NO, backup::MODE_GENERAL, $userid, $target);
+        $target == backup::TARGET_NEW_COURSE ?: $rc->get_plan()->get_setting('overwrite_conf')->set_value(true);
+        $this->assertTrue($rc->execute_precheck());
+        $rc->execute_plan();
+
+        $course = $DB->get_record('course', array('id' => $rc->get_courseid()));
+
+        $rc->destroy();
+        unset($rc);
+        return $course;
+    }
+
+    /**
+     * Test backup and restore of custom fields
+     */
+    public function test_restore_customfields() {
+        global $USER;
+        $dg = $this->getDataGenerator();
+
+        $c1 = $dg->create_course(['shortname' => 'SN', 'fullname' => 'FN',
+            'summary' => 'DESC', 'summaryformat' => FORMAT_MOODLE,
+            'customfield_f1' => 'some text', 'customfield_f2' => 1]);
+        $backupid = $this->backup_course($c1->id);
+
+        // The information is restored but adapted because names are already taken.
+        $c2 = $this->restore_course($backupid, 0, $USER->id);
+
+        $data = \core_course\customfield\course_handler::create()->export_instance_data_object($c1->id);
+        $this->assertEquals('some text', $data->f1);
+        $this->assertEquals('Yes', $data->f2);
+    }
+
+    /**
+     * Delete a category that has fields and the fields have data.
+     */
+    public function test_delete_category() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+
+        $now = time();
+        $c1 = $dg->create_course(['shortname' => 'SN', 'fullname' => 'FN',
+            'summary' => 'DESC', 'summaryformat' => FORMAT_MOODLE,
+            'customfield_f1' => 'some text', 'customfield_f2' => 1,
+            'customfield_f3' => $now, 'customfield_f4' => 2,
+            'customfield_f5_editor' => ['text' => 'test', 'format' => FORMAT_HTML]]);
+
+        // Find the category and delete it.
+        $cats = \core_course\customfield\course_handler::create()->get_categories_with_fields();
+        $cat = reset($cats);
+        $cat->get_handler()->delete_category($cat);
+
+        // Course no longer has the customfield properties.
+        $course = course_get_format($c1->id)->get_course();
+        $keys = array_keys((array)$course);
+        $this->assertEmpty(array_intersect($keys, ['customfield_f1', 'customfield_f2',
+            'customfield_f3', 'customfield_f4', 'customfield_f5']));
+
+        // Nothing in customfield tables either.
+        $this->assertEquals(0, count($DB->get_records('customfield_field')));
+        $this->assertEquals(0, count($DB->get_records('customfield_data')));
+    }
+
+}
index 39c187f..d4b6881 100644 (file)
@@ -409,6 +409,14 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         // Enable course themes.
         set_config('allowcoursethemes', 1);
 
+        // Custom fields
+        $fieldcategory = self::getDataGenerator()->create_custom_field_category(['name' => 'Other fields']);
+
+        $customfield = ['shortname' => 'test', 'name' => 'Custom field', 'type' => 'text',
+            'categoryid' => $fieldcategory->get('id'),
+            'configdata' => ['visibility' => \core_course\customfield\course_handler::VISIBLETOALL]];
+        $field = self::getDataGenerator()->create_custom_field($customfield);
+
         // Set the required capabilities by the external function
         $contextid = context_system::instance()->id;
         $roleid = $this->assignUserCapability('moodle/course:create', $contextid);
@@ -456,7 +464,11 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         foreach ($course3options as $key => $value) {
             $course3['courseformatoptions'][] = array('name' => $key, 'value' => $value);
         }
-        $courses = array($course1, $course2, $course3);
+        $course4['fullname'] = 'Test course with custom fields';
+        $course4['shortname'] = 'Testcoursecustomfields';
+        $course4['categoryid'] = $category->id;
+        $course4['customfields'] = [['shortname' => $customfield['shortname'], 'value' => 'Test value']];
+        $courses = array($course4, $course1, $course2, $course3);
 
         $createdcourses = core_course_external::create_courses($courses);
 
@@ -464,7 +476,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $createdcourses = external_api::clean_returnvalue(core_course_external::create_courses_returns(), $createdcourses);
 
         // Check that right number of courses were created.
-        $this->assertEquals(3, count($createdcourses));
+        $this->assertEquals(4, count($createdcourses));
 
         // Check that the courses were correctly created.
         foreach ($createdcourses as $createdcourse) {
@@ -520,6 +532,14 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                 $this->assertEquals(course_get_format($createdcourse['id'])->get_last_section_number(),
                     $course3options['numsections']);
                 $this->assertEquals($courseinfo->coursedisplay, $course3options['coursedisplay']);
+            } else if ($createdcourse['shortname'] == $course4['shortname']) {
+                $this->assertEquals($courseinfo->fullname, $course4['fullname']);
+                $this->assertEquals($courseinfo->shortname, $course4['shortname']);
+                $this->assertEquals($courseinfo->category, $course4['categoryid']);
+
+                $handler = core_course\customfield\course_handler::create();
+                $customfields = $handler->export_instance_data_object($createdcourse['id']);
+                $this->assertEquals((object)['test' => 'Test value'], $customfields);
             } else {
                 throw new moodle_exception('Unexpected shortname');
             }
@@ -608,11 +628,22 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $coursedata['summaryformat'] = FORMAT_MOODLE;
         $course1  = self::getDataGenerator()->create_course($coursedata);
 
+        $fieldcategory = self::getDataGenerator()->create_custom_field_category(
+            ['name' => 'Other fields']);
+
+        $customfield = ['shortname' => 'test', 'name' => 'Custom field', 'type' => 'text',
+            'categoryid' => $fieldcategory->get('id')];
+        $field = self::getDataGenerator()->create_custom_field($customfield);
+
+        $customfieldvalue = ['shortname' => 'test', 'value' => 'Test value'];
+
         $generatedcourses[$course1->id] = $course1;
         $course2  = self::getDataGenerator()->create_course();
         $generatedcourses[$course2->id] = $course2;
         $course3  = self::getDataGenerator()->create_course(array('format' => 'topics'));
         $generatedcourses[$course3->id] = $course3;
+        $course4  = self::getDataGenerator()->create_course(['customfields' => [$customfieldvalue]]);
+        $generatedcourses[$course4->id] = $course4;
 
         // Set the required capabilities by the external function.
         $context = context_system::instance();
@@ -623,15 +654,17 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                 context_course::instance($course2->id)->id, $roleid);
         $this->assignUserCapability('moodle/course:update',
                 context_course::instance($course3->id)->id, $roleid);
+        $this->assignUserCapability('moodle/course:update',
+                context_course::instance($course4->id)->id, $roleid);
 
         $courses = core_course_external::get_courses(array('ids' =>
-            array($course1->id, $course2->id)));
+            array($course1->id, $course2->id, $course4->id)));
 
         // We need to execute the return values cleaning process to simulate the web service server.
         $courses = external_api::clean_returnvalue(core_course_external::get_courses_returns(), $courses);
 
-        // Check we retrieve the good total number of categories.
-        $this->assertEquals(2, count($courses));
+        // Check we retrieve the good total number of courses.
+        $this->assertEquals(3, count($courses));
 
         foreach ($courses as $course) {
             $coursecontext = context_course::instance($course['id']);
@@ -668,6 +701,9 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                     array('name' => 'coursedisplay', 'value' => $dbcourse->coursedisplay),
                 ));
             }
+            if ($dbcourse->id == 4) {
+                $this->assertEquals($course['customfields'], [array_merge($customfield, $customfieldvalue)]);
+            }
         }
 
         // Get all courses in the DB
@@ -728,6 +764,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $page = new moodle_page();
         $page->set_course($course2);
         $page->blocks->add_blocks([BLOCK_POS_LEFT => ['news_items'], BLOCK_POS_RIGHT => []], 'course-view-*');
+
         // Search by name.
         $results = core_course_external::search_courses('search', 'FIRST');
         $results = external_api::clean_returnvalue(core_course_external::search_courses_returns(), $results);
@@ -753,6 +790,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                 array('TAG-LABEL ON SECOND COURSE'));
         $taginstance = $DB->get_record('tag_instance',
                 array('itemtype' => 'course', 'itemid' => $course2->id), '*', MUST_EXIST);
+
         // Search by tagid.
         $results = core_course_external::search_courses('tagid', $taginstance->tagid);
         $results = external_api::clean_returnvalue(core_course_external::search_courses_returns(), $results);
@@ -782,7 +820,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assertEquals(1, $results['total']);
         $this->assertEquals($coursedata1['fullname'], $results['courses'][0]['fullname']);
 
-        // Check that we can see both without the limit to enrolled setting.
+        // Check that we can see all courses without the limit to enrolled setting.
         $results = core_course_external::search_courses('search', 'COURSE', 0, 0, array(), 0);
         $results = external_api::clean_returnvalue(core_course_external::search_courses_returns(), $results);
         $this->assertCount(2, $results['courses']);
@@ -798,7 +836,6 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         // Search by block (use news_items default block). Should fail (only admins allowed).
         $this->expectException('required_capability_exception');
         $results = core_course_external::search_courses('blocklist', $blockid);
-
     }
 
     /**
@@ -1254,6 +1291,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $contextid = context_system::instance()->id;
         $roleid = $this->assignUserCapability('moodle/course:update', $contextid);
         $this->assignUserCapability('moodle/course:changecategory', $contextid, $roleid);
+        $this->assignUserCapability('moodle/course:changelockedcustomfields', $contextid, $roleid);
         $this->assignUserCapability('moodle/course:changefullname', $contextid, $roleid);
         $this->assignUserCapability('moodle/course:changeshortname', $contextid, $roleid);
         $this->assignUserCapability('moodle/course:changeidnumber', $contextid, $roleid);
@@ -1262,19 +1300,32 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assignUserCapability('moodle/course:viewhiddencourses', $contextid, $roleid);
         $this->assignUserCapability('moodle/course:setforcedlanguage', $contextid, $roleid);
 
-        // Create category and course.
+        // Create category and courses.
         $category1  = self::getDataGenerator()->create_category();
         $category2  = self::getDataGenerator()->create_category();
+
         $originalcourse1 = self::getDataGenerator()->create_course();
         self::getDataGenerator()->enrol_user($USER->id, $originalcourse1->id, $roleid);
+
         $originalcourse2 = self::getDataGenerator()->create_course();
         self::getDataGenerator()->enrol_user($USER->id, $originalcourse2->id, $roleid);
 
+        // Course with custom fields.
+        $fieldcategory = self::getDataGenerator()->create_custom_field_category(['name' => 'Other fields']);
+        $customfield = ['shortname' => 'test', 'name' => 'Custom field', 'type' => 'text',
+            'categoryid' => $fieldcategory->get('id'),
+            'configdata' => ['visibility' => \core_course\customfield\course_handler::VISIBLETOALL, 'locked' => 1]];
+        $field = self::getDataGenerator()->create_custom_field($customfield);
+
+        $originalcourse3 = self::getDataGenerator()->create_course(['customfield_test' => 'Test value']);
+        self::getDataGenerator()->enrol_user($USER->id, $originalcourse3->id, $roleid);
+
         // Course values to be updated.
         $course1['id'] = $originalcourse1->id;
         $course1['fullname'] = 'Updated test course 1';
         $course1['shortname'] = 'Udestedtestcourse1';
         $course1['categoryid'] = $category1->id;
+
         $course2['id'] = $originalcourse2->id;
         $course2['fullname'] = 'Updated test course 2';
         $course2['shortname'] = 'Updestedtestcourse2';
@@ -1297,11 +1348,15 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $course2['enablecompletion'] = 1;
         $course2['lang'] = 'en';
         $course2['forcetheme'] = 'bootstrapbase';
-        $courses = array($course1, $course2);
+
+        $course3['id'] = $originalcourse3->id;
+        $updatedcustomfieldvalue = ['shortname' => 'test', 'value' => 'Updated test value'];
+        $course3['customfields'] = [$updatedcustomfieldvalue];
+        $courses = array($course1, $course2, $course3);
 
         $updatedcoursewarnings = core_course_external::update_courses($courses);
         $updatedcoursewarnings = external_api::clean_returnvalue(core_course_external::update_courses_returns(),
-                                                                    $updatedcoursewarnings);
+                $updatedcoursewarnings);
         $COURSE = $origcourse; // Restore $COURSE. Instead of using the OLD one set by the previous line.
 
         // Check that right number of courses were created.
@@ -1310,6 +1365,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         // Check that the courses were correctly created.
         foreach ($courses as $course) {
             $courseinfo = course_get_format($course['id'])->get_course();
+            $customfields = \core_course\customfield\course_handler::create()->export_instance_data_object($course['id']);
             if ($course['id'] == $course2['id']) {
                 $this->assertEquals($course2['fullname'], $courseinfo->fullname);
                 $this->assertEquals($course2['shortname'], $courseinfo->shortname);
@@ -1336,6 +1392,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                 }
 
                 $this->assertEquals($course2['enablecompletion'], $courseinfo->enablecompletion);
+                $this->assertEquals(['test' => null], (array)$customfields);
             } else if ($course['id'] == $course1['id']) {
                 $this->assertEquals($course1['fullname'], $courseinfo->fullname);
                 $this->assertEquals($course1['shortname'], $courseinfo->shortname);
@@ -1345,6 +1402,9 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                 $this->assertEquals(5, course_get_format($course['id'])->get_last_section_number());
                 $this->assertEquals(0, $courseinfo->newsitems);
                 $this->assertEquals(FORMAT_MOODLE, $courseinfo->summaryformat);
+                $this->assertEquals(['test' => null], (array)$customfields);
+            } else if ($course['id'] == $course3['id']) {
+                $this->assertEquals(['test' => $updatedcustomfieldvalue['value']], (array)$customfields);
             } else {
                 throw new moodle_exception('Unexpected shortname');
             }
@@ -1475,6 +1535,21 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $updatedcoursewarnings = external_api::clean_returnvalue(core_course_external::update_courses_returns(),
                                                                     $updatedcoursewarnings);
         $this->assertEquals(1, count($updatedcoursewarnings['warnings']));
+
+        // Try update course custom fields without capability.
+        $this->unassignUserCapability('moodle/course:changelockedcustomfields', $contextid, $roleid);
+        $user = self::getDataGenerator()->create_user();
+        $this->setUser($user);
+        self::getDataGenerator()->enrol_user($user->id, $course3['id'], $roleid);
+
+        $newupdatedcustomfieldvalue = ['shortname' => 'test', 'value' => 'New updated value'];
+        $course3['customfields'] = [$newupdatedcustomfieldvalue];
+
+        core_course_external::update_courses([$course3]);
+
+        // Custom field was not updated.
+        $customfields = \core_course\customfield\course_handler::create()->export_instance_data_object($course3['id']);
+        $this->assertEquals(['test' => $updatedcustomfieldvalue['value']], (array)$customfields);
     }
 
     /**
index f574779..20b2fcb 100644 (file)
@@ -27,6 +27,14 @@ $string['aria:courseimage'] = 'Course image';
 $string['aria:courseshortname'] = 'Course short name';
 $string['aria:coursename'] = 'Course name';
 $string['aria:favourite'] = 'Course is starred';
+$string['customfield_islocked'] = 'Locked';
+$string['customfield_islocked_help'] = 'When the field is locked only managers with capability "Modify locked fields" will be able to change it in the course editing form';
+$string['customfield_notvisible'] = 'Not visible';
+$string['customfield_visibility'] = 'Visible to';
+$string['customfield_visibility_help'] = 'Who should be able able to see the data in the course listing';
+$string['customfield_visibletoall'] = 'Everyone';
+$string['customfield_visibletoteachers'] = 'Course editors';
+$string['customfieldsettings'] = 'Settings for course custom fields';
 $string['favourite'] = 'Starred course';
 $string['privacy:perpage'] = 'The number of courses to show per page.';
 $string['privacy:completionpath'] = 'Course completion';
index 743bc48..04bc391 100644 (file)
@@ -161,8 +161,10 @@ $string['course:viewsuspendedusers'] = 'View suspended users';
 $string['course:changecategory'] = 'Change course category';
 $string['course:changefullname'] = 'Change course full name';
 $string['course:changeidnumber'] = 'Change course ID number';
+$string['course:changelockedcustomfields'] = 'Modify locked custom fields';
 $string['course:changeshortname'] = 'Change course short name';
 $string['course:changesummary'] = 'Change course summary';
+$string['course:configurecustomfields'] = 'Configure custom fields';
 $string['course:enrolconfig'] = 'Configure enrol instances in courses';
 $string['course:enrolreview'] = 'Review course enrolments';
 $string['course:setforcedlanguage'] = 'Force course language';
index 46164d5..d66d5eb 100644 (file)
@@ -1026,6 +1026,26 @@ $capabilities = array(
         'clonepermissionsfrom' => 'moodle/course:update'
     ),
 
+    'moodle/course:changelockedcustomfields' => array(
+
+        'riskbitmask' => RISK_SPAM,
+
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'manager' => CAP_ALLOW
+        ),
+    ),
+
+    'moodle/course:configurecustomfields' => array(
+
+        'riskbitmask' => RISK_SPAM,
+
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_SYSTEM,
+        'clonepermissionsfrom' => 'moodle/site:config'
+    ),
+
     'moodle/course:renameroles' => array(
         'captype' => 'write',
         'contextlevel' => CONTEXT_COURSE,
index d3b85d2..864719a 100644 (file)
@@ -4956,6 +4956,9 @@ function delete_course($courseorid, $showfeedback = true) {
         }
     }
 
+    $handler = core_course\customfield\course_handler::create();
+    $handler->delete_instance($courseid);
+
     // Make the course completely empty.
     remove_course_contents($courseid, $showfeedback);