MDL-43056 tool_uploadcourse: Add capability to upload courses from file

An entrypoint capability has been added that allows accessing the
upload tool. Further relevant capability checks are then performed
depending on the action being taken during the upload process.

Co-authored-by: Marina Glancy <marina@moodle.com>
This commit is contained in:
David Woloszyn 2024-01-31 11:29:33 +11:00
parent f30110b5eb
commit 4807a4dd5f
27 changed files with 717 additions and 53 deletions

View file

@ -22,6 +22,8 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use tool_uploadcourse\permissions;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
require_once($CFG->dirroot . '/course/lib.php');
@ -448,6 +450,11 @@ class tool_uploadcourse_course {
return false;
}
if ($error = permissions::check_permission_to_delete($this->shortname)) {
$this->error('coursedeletionpermission', $error);
return false;
}
$this->do = self::DO_DELETE;
return true;
}
@ -680,9 +687,20 @@ class tool_uploadcourse_course {
return false;
}
if ($error = permissions::check_permission_to_update($coursedata)) {
$this->error('cannotupdatepermission', $error);
return false;
}
$this->do = self::DO_UPDATE;
} else {
$coursedata = $this->get_final_create_data($coursedata);
if ($error = permissions::check_permission_to_create($coursedata)) {
$this->error('courseuploadnotallowed', $error);
return false;
}
$this->do = self::DO_CREATE;
}
@ -799,6 +817,7 @@ class tool_uploadcourse_course {
$this->data = $coursedata;
// Get enrolment data. Where the course already exists, we can also perform validation.
// Some data is impossible to validate without the existing course, we will do it again during actual upload.
$this->enrolmentdata = tool_uploadcourse_helper::get_enrolment_data($this->rawdata);
$courseid = $coursedata['id'] ?? 0;
$errors = $this->validate_enrolment_data($courseid, $this->enrolmentdata);
@ -823,6 +842,11 @@ class tool_uploadcourse_course {
return false;
}
if ($this->restoredata && ($error = permissions::check_permission_to_restore($this->do, $this->data))) {
$this->error('courserestorepermission', $error);
return false;
}
// We can only reset courses when allowed and we are updating the course.
if ($this->importoptions['reset'] || $this->options['reset']) {
if ($this->do !== self::DO_UPDATE) {
@ -833,6 +857,11 @@ class tool_uploadcourse_course {
$this->error('courseresetnotallowed', new lang_string('courseresetnotallowed', 'tool_uploadcourse'));
return false;
}
if ($error = permissions::check_permission_to_reset($this->data)) {
$this->error('courseresetpermission', $error);
return false;
}
}
return true;
@ -889,7 +918,7 @@ class tool_uploadcourse_course {
$rc->execute_plan();
$this->status('courserestored', new lang_string('courserestored', 'tool_uploadcourse'));
} else {
$this->error('errorwhilerestoringcourse', new lang_string('errorwhilerestoringthecourse', 'tool_uploadcourse'));
$this->error('errorwhilerestoringcourse', new lang_string('errorwhilerestoringcourse', 'tool_uploadcourse'));
}
$rc->destroy();
}
@ -913,7 +942,7 @@ class tool_uploadcourse_course {
/**
* Validate passed enrolment data against an existing course
*
* @param int $courseid
* @param int $courseid id of the course where enrolment methods are created/updated or 0 if it is a new course
* @param array[] $enrolmentdata
* @return lang_string[] Errors keyed on error code
*/
@ -1050,8 +1079,13 @@ class tool_uploadcourse_course {
// Create/update enrolment.
$plugin = $enrolmentplugins[$enrolmethod];
if ($plugin->is_csv_upload_supported()) {
// In case we could not properly validate enrolment data before the course existed
// let's repeat it again here.
$errors = $plugin->validate_enrol_plugin_data($method, $course->id);
if (!$errors) {
$status = ($todisable) ? ENROL_INSTANCE_DISABLED : ENROL_INSTANCE_ENABLED;
$method += ['status' => $status, 'courseid' => $course->id, 'id' => $instance->id ?? null];
$method = $plugin->fill_enrol_custom_fields($method, $course->id);
// Create a new instance if necessary.
@ -1150,9 +1184,9 @@ class tool_uploadcourse_course {
$plugin->update_instance($instance, $modifiedinstance);
} else {
$this->error('errorunsupportedmethod',
new lang_string('errorunsupportedmethod', 'tool_uploadcourse',
$enrolmethod));
foreach ($errors as $key => $message) {
$this->error($key, $message);
}
}
}
}

View file

@ -540,6 +540,11 @@ class tool_uploadcourse_helper {
$params = array('idnumber' => $idnumber);
$id = $DB->get_field_select('course_categories', 'id', 'idnumber = :idnumber', $params, IGNORE_MISSING);
if ($id && !core_course_category::get($id, IGNORE_MISSING)) {
// Category is not visible to the current user.
$id = false;
}
// Little hack to be able to differenciate between the cache not set and a category not found.
if ($id === false) {
$id = -1;
@ -578,6 +583,11 @@ class tool_uploadcourse_helper {
break;
}
$record = reset($records);
if (!core_course_category::get($id, IGNORE_MISSING)) {
// Category is not visible to the current user.
$id = -1;
break;
}
$id = $record->id;
$parent = $record->id;
} else {

View file

@ -0,0 +1,262 @@
<?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/>.
namespace tool_uploadcourse;
use context_course;
use context_coursecat;
use core_course_category;
use core_tag_tag;
use lang_string;
use tool_uploadcourse_course;
/**
* Checks various permissions related to the course upload process.
*
* @package tool_uploadcourse
* @copyright 2019 Marina Glancy
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class permissions {
/**
* Check permission to use tool_uploadcourse in a given category.
*
* @param int $catid
* @param lang_string|null $customerror
* @return lang_string|null
*/
protected static function check_permission_to_use_uploadcourse_tool(
int $catid,
?lang_string $customerror = null
): ?lang_string {
$category = core_course_category::get($catid, IGNORE_MISSING);
if (!$category || !has_capability('tool/uploadcourse:use', $category->get_context())) {
if ($customerror) {
return $customerror;
}
return new lang_string('courseuploadnotallowed', 'tool_uploadcourse',
$category ? $category->get_formatted_name() : $catid);
}
return null;
}
/**
* Check capabilities to delete a course and to use tool_uploadcourse for it.
*
* @param string $shortname course shortname
* @return lang_string|null
*/
public static function check_permission_to_delete(string $shortname): ?lang_string {
global $DB;
$course = $DB->get_record('course', ['shortname' => $shortname]);
if ($error = self::check_permission_to_use_uploadcourse_tool($course->category)) {
return $error;
}
if (!has_capability('moodle/course:delete', context_course::instance($course->id))) {
return new lang_string('nopermissions', 'error', get_capability_string('moodle/course:delete'));
}
return null;
}
/**
* Check capability in a course (that exists or is about to be created).
*
* @param int $do one of tool_uploadcourse_course::DO_UPDATE or tool_uploadcourse_course::DO_ADD
* @param array $coursedata data to update/create course with, must contain either 'id' or 'category' respectively
* @param string $capability capability to check
* @return lang_string|null error string or null
*/
protected static function check_capability(int $do, array $coursedata, string $capability): ?lang_string {
if ($do == tool_uploadcourse_course::DO_UPDATE) {
$context = context_course::instance($coursedata['id']);
$hascap = has_capability($capability, $context);
} else {
$catcontext = context_coursecat::instance($coursedata['category']);
$hascap = guess_if_creator_will_have_course_capability($capability, $catcontext);
}
if (!$hascap) {
return new lang_string('nopermissions', 'error', get_capability_string($capability));
}
return null;
}
/**
* Check permission to update the course.
*
* This checks capabilities:
* - to use tool_uploadcourse in the category where course is in and in the category where it will be moved to (if applicable).
* - to change course category (if applicable).
* - to update course details.
* - to force course language (if applicable).
* - to change course idnumber, shortname, fullname, summary, visibility, tags (if applicable).
*
* @param array $coursedata data to update a course with, always contains 'id'
* @return lang_string|null
*/
public static function check_permission_to_update(array $coursedata): ?lang_string {
$course = get_course($coursedata['id']);
if ($error = self::check_permission_to_use_uploadcourse_tool($course->category,
new lang_string('courseuploadupdatenotallowed', 'tool_uploadcourse'))) {
return $error;
}
if (!has_capability('moodle/course:update', context_course::instance($course->id))) {
return new lang_string('nopermissions', 'error', get_capability_string('moodle/course:update'));
}
// If user requested to change course category check permissions to use tool in target category
// and capabilities to change category.
if (!empty($coursedata['category']) && $coursedata['category'] != $course->category) {
if ($error = self::check_permission_to_use_uploadcourse_tool($coursedata['category'])) {
return $error;
}
if (!has_capability('moodle/course:changecategory', context_coursecat::instance($course->category))) {
return new lang_string('nopermissions', 'error', get_capability_string('moodle/course:changecategory'));
}
if (!has_capability('moodle/course:changecategory', context_coursecat::instance($coursedata['category']))) {
return new lang_string('nopermissions', 'error', get_capability_string('moodle/course:changecategory'));
}
}
$context = context_course::instance($coursedata['id']);
// If lang is specified, check the user is allowed to set that field.
if (!empty($coursedata['lang']) && $coursedata['lang'] !== $course->lang) {
if (!has_capability('moodle/course:setforcedlanguage', $context)) {
return new lang_string('cannotforcelang', 'tool_uploadcourse');
}
}
// Check permission to change course idnumber.
if (array_key_exists('idnumber', $coursedata) && $coursedata['idnumber'] !== $course->idnumber &&
!has_capability('moodle/course:changeidnumber', $context)) {
return new lang_string('nopermissions', 'error', get_capability_string('moodle/course:changeidnumber'));
}
// Check permission to change course shortname.
if (array_key_exists('shortname', $coursedata) && $coursedata['shortname'] !== $course->shortname &&
!has_capability('moodle/course:changeshortname', $context)) {
return new lang_string('nopermissions', 'error', get_capability_string('moodle/course:changeshortname'));
}
// Check permission to change course fullname.
if (array_key_exists('fullname', $coursedata) && $coursedata['fullname'] !== $course->fullname &&
!has_capability('moodle/course:changefullname', $context)) {
return new lang_string('nopermissions', 'error', get_capability_string('moodle/course:changefullname'));
}
// Check permission to change course summary.
if (array_key_exists('summary', $coursedata) && $coursedata['summary'] !== $course->summary &&
!has_capability('moodle/course:changesummary', $context)) {
return new lang_string('nopermissions', 'error', get_capability_string('moodle/course:changesummary'));
}
// Check permission to change course visibility.
if (array_key_exists('visible', $coursedata) && $coursedata['visible'] !== $course->visible &&
!has_capability('moodle/course:visibility', $context)) {
return new lang_string('nopermissions', 'error', get_capability_string('moodle/course:visibility'));
}
// If tags are specified and enabled check if user can updat them.
if (core_tag_tag::is_enabled('core', 'course') &&
(array_key_exists('tags', $coursedata) && strval($coursedata['tags']) !== '') &&
($error = self::check_capability(tool_uploadcourse_course::DO_UPDATE, $coursedata, 'moodle/course:tag'))) {
return $error;
}
return null;
}
/**
* Check permission to create course.
*
* This checks capabilities:
* - to use tool_uploadcourse in the category where course will be created.
* - to create a course.
* - to force course language (if applicable).
* - to set course tags (if applicable).
*
* @param array $coursedata data to create a course with, always contains 'category'
* @return lang_string|null
*/
public static function check_permission_to_create(array $coursedata): ?lang_string {
if ($error = self::check_permission_to_use_uploadcourse_tool($coursedata['category'])) {
return $error;
}
$catcontext = context_coursecat::instance($coursedata['category']);
// Check user is allowed to create courses in this category.
if (!has_capability('moodle/course:create', $catcontext)) {
return new lang_string('nopermissions', 'error', get_capability_string('moodle/course:create'));
}
// If lang is specified, check the user is allowed to set that field.
if (!empty($coursedata['lang'])) {
if (!guess_if_creator_will_have_course_capability('moodle/course:setforcedlanguage', $catcontext)) {
return new lang_string('cannotforcelang', 'tool_uploadcourse');
}
}
// Check permission to change course visibility.
if (array_key_exists('visible', $coursedata) && !$coursedata['visible'] &&
!guess_if_creator_will_have_course_capability('moodle/course:visibility', $catcontext)) {
return new lang_string('nopermissions', 'error', get_capability_string('moodle/course:visibility'));
}
// If tags are specified and enabled check if user can updat them.
if (core_tag_tag::is_enabled('core', 'course') &&
(array_key_exists('tags', $coursedata) && strval($coursedata['tags']) !== '') &&
($error = self::check_capability(tool_uploadcourse_course::DO_CREATE, $coursedata, 'moodle/course:tag'))) {
return $error;
}
return null;
}
/**
* Check if the user is able to reset a course.
*
* Capability to use the tool and update the course is already checked earlier.
*
* @param array $coursedata data to update course with, always contains 'id'
* @return lang_string|null error string or null
*/
public static function check_permission_to_reset(array $coursedata): ?lang_string {
return self::check_capability(tool_uploadcourse_course::DO_UPDATE, $coursedata, 'moodle/course:reset');
}
/**
* Check if the user is able to restore the mbz into a course.
*
* This method does not need to check if the course can be updated/created, this is checked earlier.
*
* @param int $do one of tool_uploadcourse_course::DO_UPDATE or tool_uploadcourse_course::DO_ADD
* @param array $coursedata data to update/create course with, must contain either 'id' or 'category' respectively
* @return lang_string|null error string or null
*/
public static function check_permission_to_restore(int $do, array $coursedata): ?lang_string {
return self::check_capability($do, $coursedata, 'moodle/restore:restorecourse');
}
}

View file

@ -174,7 +174,7 @@ class tool_uploadcourse_processor {
/**
* Execute the process.
*
* @param object $tracker the output tracker to use.
* @param tool_uploadcourse_tracker $tracker the output tracker to use.
* @return void
*/
public function execute($tracker = null) {
@ -324,7 +324,7 @@ class tool_uploadcourse_processor {
* This only returns passed data, along with the errors.
*
* @param integer $rows number of rows to preview.
* @param object $tracker the output tracker to use.
* @param tool_uploadcourse_tracker $tracker the output tracker to use.
* @return array of preview data.
*/
public function preview($rows = 10, $tracker = null) {

View file

@ -74,6 +74,9 @@ class tool_uploadcourse_step1_form extends tool_uploadcourse_base_form {
$mform->addElement('hidden', 'showpreview', 1);
$mform->setType('showpreview', PARAM_INT);
$mform->addElement('hidden', 'categoryid');
$mform->setType('categoryid', PARAM_INT);
$this->add_action_buttons(false, get_string('preview', 'tool_uploadcourse'));
}
}

View file

@ -82,7 +82,7 @@ class tool_uploadcourse_step2_form extends tool_uploadcourse_base_form {
$mform->addElement('header', 'defaultheader', get_string('defaultvalues', 'tool_uploadcourse'));
$mform->setExpanded('defaultheader', true);
$displaylist = core_course_category::make_categories_list('moodle/course:create');
$displaylist = core_course_category::make_categories_list('tool/uploadcourse:use');
$mform->addElement('autocomplete', 'defaults[category]', get_string('coursecategory'), $displaylist);
$mform->addRule('defaults[category]', null, 'required', null, 'client');
$mform->addHelpButton('defaults[category]', 'coursecategory');
@ -223,6 +223,9 @@ class tool_uploadcourse_step2_form extends tool_uploadcourse_base_form {
$mform->addElement('hidden', 'previewrows');
$mform->setType('previewrows', PARAM_INT);
$mform->addElement('hidden', 'categoryid');
$mform->setType('categoryid', PARAM_INT);
$this->add_action_buttons(true, get_string('uploadcourses', 'tool_uploadcourse'));
// Prepare custom fields data.

View file

@ -0,0 +1,36 @@
<?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/>.
/**
* Capability definitions for tool_uploadcourse.
*
* @package tool_uploadcourse
* @copyright 2019 Marina Glancy
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$capabilities = [
'tool/uploadcourse:use' => [
'riskbitmask' => RISK_SPAM,
'captype' => 'write',
'contextlevel' => CONTEXT_COURSECAT,
'archetypes' => [
'manager' => CAP_ALLOW,
],
],
];

View file

@ -26,15 +26,31 @@ require(__DIR__ . '/../../../config.php');
require_once($CFG->libdir . '/adminlib.php');
require_once($CFG->libdir . '/csvlib.class.php');
admin_externalpage_setup('tooluploadcourse');
$importid = optional_param('importid', '', PARAM_INT);
$categoryid = optional_param('categoryid', 0, PARAM_INT);
$previewrows = optional_param('previewrows', 10, PARAM_INT);
$returnurl = new moodle_url('/admin/tool/uploadcourse/index.php');
if ($categoryid) {
// When categoryid is specified, setup the page for this category and check capability in its context.
require_login(null, false);
$category = core_course_category::get($categoryid);
$categoryname = isset($category) ? $category->get_formatted_name() : $SITE->fullname;
$context = context_coursecat::instance($categoryid);
require_capability('tool/uploadcourse:use', $context);
$PAGE->set_context($context);
$PAGE->set_url(new moodle_url('/admin/tool/uploadcourse/index.php', ['categoryid' => $categoryid]));
$PAGE->set_pagelayout('admin');
$PAGE->set_title("$categoryname: " . get_string('uploadcourses', 'tool_uploadcourse'));
$PAGE->set_heading($categoryname);
} else {
admin_externalpage_setup('tooluploadcourse');
}
if (empty($importid)) {
$mform1 = new tool_uploadcourse_step1_form();
$mform1->set_data(['categoryid' => $categoryid]);
if ($form1data = $mform1->get_data()) {
$importid = csv_import_reader::get_new_iid('uploadcourse');
$cir = new csv_import_reader($importid, 'uploadcourse');
@ -58,7 +74,8 @@ if (empty($importid)) {
}
// Data to set in the form.
$data = array('importid' => $importid, 'previewrows' => $previewrows);
$categorydefaults = $categoryid ? ['category' => $categoryid] : [];
$data = ['importid' => $importid, 'previewrows' => $previewrows, 'categoryid' => $categoryid, 'defaults' => $categorydefaults];
if (!empty($form1data)) {
// Get options from the first form to pass it onto the second.
foreach ($form1data->options as $key => $value) {
@ -117,7 +134,7 @@ if ($form2data = $mform2->is_cancelled()) {
// Weird but we still need to provide a value, setting the default step1_form one.
$options = array('mode' => tool_uploadcourse_processor::MODE_CREATE_NEW);
}
$processor = new tool_uploadcourse_processor($cir, $options, array());
$processor = new tool_uploadcourse_processor($cir, $options, $categorydefaults);
echo $OUTPUT->header();
echo $OUTPUT->heading(get_string('uploadcoursespreview', 'tool_uploadcourse'));
$processor->preview($previewrows, new tool_uploadcourse_tracker(tool_uploadcourse_tracker::OUTPUT_HTML));

View file

@ -67,6 +67,8 @@ $string['coursetemplatename'] = 'Restore from this course after upload';
$string['coursetemplatename_help'] = 'Enter an existing course shortname to use as a template for the creation of all courses.';
$string['coursetorestorefromdoesnotexist'] = 'The course to restore from does not exist';
$string['courseupdated'] = 'Course updated';
$string['courseuploadnotallowed'] = 'No permission to upload courses in category: {$a}';
$string['courseuploadupdatenotallowed'] = "Course with this shortname exists and you don't have permission to use upload course tool to update it";
$string['createall'] = 'Create all, increment shortname if needed';
$string['createnew'] = 'Create new courses only, skip existing ones';
$string['createorupdate'] = 'Create new courses, or update existing ones';
@ -128,6 +130,7 @@ $string['updatemodedoessettonothing'] = 'Update mode does not allow anything to
$string['updateonly'] = 'Only update existing courses';
$string['updatewithdataordefaults'] = 'Update with CSV data and defaults';
$string['updatewithdataonly'] = 'Update with CSV data only';
$string['uploadcourse:use'] = 'Use upload course tool';
$string['uploadcourses'] = 'Upload courses';
$string['uploadcourses_help'] = 'Courses may be uploaded via text file. The format of the file should be as follows:

View file

@ -0,0 +1,44 @@
<?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/>.
/**
* Plugin callbacks for tool_uploadcourse.
*
* @package tool_uploadcourse
* @copyright 2019 Marina Glancy
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* Extends the navigation of the category admin menu with the upload courses link.
*
* @param navigation_node $navigation The navigation node to extend
* @param context $coursecategorycontext The context of the course category
*/
function tool_uploadcourse_extend_navigation_category_settings(navigation_node $navigation, context $coursecategorycontext): void {
if (has_capability('tool/uploadcourse:use', $coursecategorycontext)) {
$title = get_string('uploadcourses', 'tool_uploadcourse');
$path = new moodle_url('/admin/tool/uploadcourse/index.php', ['categoryid' => $coursecategorycontext->instanceid]);
$settingsnode = navigation_node::create(
$title,
$path,
navigation_node::TYPE_SETTING,
null,
null,
new pix_icon('i/course', ''));
$navigation->add_node($settingsnode);
}
}

View file

@ -24,7 +24,10 @@
defined('MOODLE_INTERNAL') || die();
if ($hassiteconfig) {
$ADMIN->add('courses', new admin_externalpage('tooluploadcourse',
get_string('uploadcourses', 'tool_uploadcourse'), "$CFG->wwwroot/$CFG->admin/tool/uploadcourse/index.php"));
}
$ADMIN->add('courses',
new admin_externalpage('tooluploadcourse',
get_string('uploadcourses', 'tool_uploadcourse'),
"$CFG->wwwroot/$CFG->admin/tool/uploadcourse/index.php",
'tool/uploadcourse:use'
)
);

View file

@ -145,3 +145,39 @@ Feature: An admin can create courses using a CSV file
And I am on the "C2" "enrolment methods" page
And I should see "manualtest"
And I should not see "ltitest"
@javascript
Scenario: Manager can use upload course tool in course category
Given the following "users" exist:
| username | firstname | lastname | email |
| user1 | User | 1 | user1@example.com |
And the following "categories" exist:
| name | category | idnumber |
| Cat 1 | 0 | CAT1 |
| Cat 2 | 0 | CAT2 |
| Cat 3 | CAT1 | CAT3 |
And the following "role assigns" exist:
| user | role | contextlevel | reference |
| user1 | manager | Category | CAT1 |
When I log in as "user1"
And I am on course index
And I follow "Cat 1"
And I navigate to "Upload courses" in current page administration
And I upload "admin/tool/uploadcourse/tests/fixtures/courses_manager1.csv" file to "File" filemanager
And I click on "Preview" "button"
Then I should see "The course exists and update is not allowed" in the "C1" "table_row"
And I should see "No permission to upload courses in category: Cat 2" in the "C2" "table_row"
And I set the field "Course category" to "Cat 1 / Cat 3"
And I click on "Upload courses" "button"
And I should see "Course created"
And I should see "Courses total: 5"
And I should see "Courses created: 3"
And I should see "Courses errors: 2"
And I am on course index
And I follow "Cat 1"
And I should see "Course 4"
And I follow "Cat 3"
And I should see "Course 5"
# Course 3 did not have category specified in CSV file and it was uploaded to the current category.
And I should see "Course 3"
And I log out

View file

@ -87,3 +87,50 @@ Feature: An admin can update courses using a CSV file
And I should not see "Manual enrolments"
And I should see "Published course"
And I should not see "ltitest"
@javascript
Scenario: Manager can use upload course tool to update courses in course category
Given the following "users" exist:
| username | firstname | lastname | email |
| user1 | User | 1 | user1@example.com |
And the following "categories" exist:
| name | category | idnumber |
| Cat 1 | 0 | CAT1 |
| Cat 2 | 0 | CAT2 |
| Cat 3 | CAT1 | CAT3 |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C01 | CAT1 |
| Course 2 | C02 | CAT2 |
| Course 3 | C03 | CAT3 |
| Course 4 | C04 | CAT3 |
And the following "role assigns" exist:
| user | role | contextlevel | reference |
| user1 | manager | Category | CAT1 |
When I log in as "user1"
And I am on course index
And I follow "Cat 1"
And I navigate to "Upload courses" in current page administration
And I upload "admin/tool/uploadcourse/tests/fixtures/courses_manager2.csv" file to "File" filemanager
And I set the field "Upload mode" to "Only update existing courses"
And I set the field "Update mode" to "Update with CSV data only"
And I click on "Preview" "button"
# Course C01 is in "our" category but can not be moved to Cat 2 because current user can not manage it.
Then I should see "No permission to upload courses in category: Cat 2" in the "C01" "table_row"
# Course C02 can not be updated (no capability in "Cat 2" context).
And I should see "Course with this shortname exists and you don't have permission to use upload course tool to update it" in the "C02" "table_row"
# Course with short name "C05" does not exist.
And I should see "The course does not exist and creating course is not allowed" in the "C05" "table_row"
And I click on "Upload courses" "button"
And I should see "Course updated"
And I should see "Courses total: 5"
And I should see "Courses updated: 2"
And I should see "Courses errors: 3"
And I am on course index
And I follow "Cat 1"
# Course C04 was moved from Cat 3 to Cat 1.
And I should see "Course 4"
And I should see "Course 1"
And I follow "Cat 3"
And I should see "Course 3"
And I log out

View file

@ -22,14 +22,54 @@ use tool_uploadcourse_course;
/**
* Course test case.
*
* @covers \tool_uploadcourse_course
* @package tool_uploadcourse
* @copyright 2013 Frédéric Massart
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or late
*/
class course_test extends \advanced_testcase {
public function test_proceed_without_prepare() {
/** @var \testing_data_generator $datagenerator */
protected $datagenerator;
/** @var \stdClass $user */
protected $user;
/**
* Initialise defaults for each testcase.
*
* @param int $roleid
* @throws \coding_exception
*/
protected function initialise_test(int $roleid = 1): void {
// Reset the database after test.
$this->resetAfterTest(true);
// Get a new data generator.
$this->datagenerator = $this->getDataGenerator();
// Create a user.
$this->prepare_user($roleid);
}
/**
* Create random user and assign default role Manager (roleid = 1).
*
* @param int $roleid
* @throws \coding_exception
*/
protected function prepare_user(int $roleid): void {
// Generate a random user.
$user = $this->datagenerator->create_user();
// Log the user in (set the $USER global variable).
$this->setUser($user);
// Assign a role to the current user.
$this->datagenerator->role_assign($roleid, $user->id, false);
}
public function test_proceed_without_prepare(): void {
$this->initialise_test();
$mode = tool_uploadcourse_processor::MODE_CREATE_NEW;
$updatemode = tool_uploadcourse_processor::UPDATE_NOTHING;
$data = array();
@ -39,7 +79,7 @@ class course_test extends \advanced_testcase {
}
public function test_proceed_when_prepare_failed() {
$this->resetAfterTest(true);
$this->initialise_test();
$mode = tool_uploadcourse_processor::MODE_CREATE_NEW;
$updatemode = tool_uploadcourse_processor::UPDATE_NOTHING;
$data = array();
@ -50,7 +90,7 @@ class course_test extends \advanced_testcase {
}
public function test_proceed_when_already_started() {
$this->resetAfterTest(true);
$this->initialise_test();
$mode = tool_uploadcourse_processor::MODE_CREATE_NEW;
$updatemode = tool_uploadcourse_processor::UPDATE_NOTHING;
$data = array('shortname' => 'test', 'fullname' => 'New course', 'summary' => 'New', 'category' => 1);
@ -62,7 +102,7 @@ class course_test extends \advanced_testcase {
}
public function test_invalid_shortname() {
$this->resetAfterTest(true);
$this->initialise_test();
$mode = tool_uploadcourse_processor::MODE_CREATE_NEW;
$updatemode = tool_uploadcourse_processor::UPDATE_NOTHING;
$data = array('shortname' => '<invalid>', 'fullname' => 'New course', 'summary' => 'New', 'category' => 1);
@ -88,7 +128,7 @@ class course_test extends \advanced_testcase {
}
public function test_invalid_fullname_too_long() {
$this->resetAfterTest();
$this->initialise_test();
$mode = tool_uploadcourse_processor::MODE_CREATE_NEW;
$updatemode = tool_uploadcourse_processor::UPDATE_NOTHING;
@ -103,7 +143,7 @@ class course_test extends \advanced_testcase {
}
public function test_invalid_visibility() {
$this->resetAfterTest(true);
$this->initialise_test();
$mode = tool_uploadcourse_processor::MODE_CREATE_NEW;
$updatemode = tool_uploadcourse_processor::UPDATE_NOTHING;
$data = array('shortname' => 'test', 'fullname' => 'New course', 'summary' => 'New', 'category' => 1, 'visible' => 2);
@ -194,9 +234,48 @@ class course_test extends \advanced_testcase {
$this->assertArrayHasKey('invaliddownloadcontent', $upload->get_errors());
}
/**
* Test a role's capability to use the upload course tool.
*
* @covers \permissions::check_permission_to_use_uploadcourse_tool
*/
public function test_invalid_role(): void {
global $DB;
$rolesallowed = ['manager'];
$roles = get_all_roles();
$mode = tool_uploadcourse_processor::MODE_CREATE_NEW;
$updatemode = tool_uploadcourse_processor::UPDATE_NOTHING;
foreach ($roles as $role) {
$this->initialise_test($role->id);
$data = ['shortname' => 'newcourse', 'fullname' => 'New course', 'summary' => 'New', 'category' => 1];
$co = new tool_uploadcourse_course($mode, $updatemode, $data);
if (in_array($role->shortname, $rolesallowed)) {
$this->assertTrue($co->prepare());
$co->proceed();
$courseid = $DB->get_field('course', 'id', ['shortname' => 'newcourse'], MUST_EXIST);
$this->assertEquals(0, course_get_format($courseid)->get_course()->coursedisplay);
// Delete course for next assertion.
$importoptions = ['candelete' => true];
$data = ['shortname' => 'newcourse', 'delete' => 1];
$co = new tool_uploadcourse_course($mode, $updatemode, $data, [], $importoptions);
$this->assertTrue($co->prepare());
$co->proceed();
} else {
$this->assertFalse($co->prepare());
$this->assertArrayHasKey('courseuploadnotallowed', $co->get_errors());
}
}
}
public function test_create() {
global $DB;
$this->resetAfterTest(true);
$this->initialise_test();
// Existing course.
$c1 = $this->getDataGenerator()->create_course(array('shortname' => 'c1', 'summary' => 'Yay!'));
@ -245,7 +324,7 @@ class course_test extends \advanced_testcase {
public function test_create_with_sections() {
global $DB;
$this->resetAfterTest(true);
$this->initialise_test();
$updatemode = tool_uploadcourse_processor::UPDATE_NOTHING;
$defaultnumsections = get_config('moodlecourse', 'numsections');
@ -275,7 +354,7 @@ class course_test extends \advanced_testcase {
public function test_delete() {
global $DB;
$this->resetAfterTest(true);
$this->initialise_test();
$c1 = $this->getDataGenerator()->create_course();
$c2 = $this->getDataGenerator()->create_course();
@ -320,7 +399,7 @@ class course_test extends \advanced_testcase {
public function test_update() {
global $DB;
$this->resetAfterTest(true);
$this->initialise_test();
$c1 = $this->getDataGenerator()->create_course(array('shortname' => 'c1'));
@ -398,8 +477,7 @@ class course_test extends \advanced_testcase {
public function test_data_saved() {
global $DB;
$this->resetAfterTest(true);
$this->setAdminUser(); // To avoid warnings related to 'moodle/course:setforcedlanguage' capability check.
$this->initialise_test();
set_config('downloadcoursecontentallowed', 1);
@ -617,8 +695,7 @@ class course_test extends \advanced_testcase {
public function test_default_data_saved() {
global $DB;
$this->resetAfterTest(true);
$this->setAdminUser();
$this->initialise_test();
set_config('downloadcoursecontentallowed', 1);
@ -742,7 +819,7 @@ class course_test extends \advanced_testcase {
public function test_rename() {
global $DB;
$this->resetAfterTest(true);
$this->initialise_test();
$c1 = $this->getDataGenerator()->create_course(array('shortname' => 'c1'));
$c2 = $this->getDataGenerator()->create_course(array('shortname' => 'c2'));
@ -837,7 +914,7 @@ class course_test extends \advanced_testcase {
public function test_restore_course() {
global $DB;
$this->resetAfterTest(true);
$this->initialise_test();
$this->setAdminUser();
$c1 = $this->getDataGenerator()->create_course();
@ -881,7 +958,7 @@ class course_test extends \advanced_testcase {
public function test_restore_file() {
global $DB;
$this->resetAfterTest(true);
$this->initialise_test();
$this->setAdminUser();
$c1 = $this->getDataGenerator()->create_course();
@ -933,7 +1010,7 @@ class course_test extends \advanced_testcase {
*/
public function test_restore_file_settings() {
global $DB;
$this->resetAfterTest(true);
$this->initialise_test();
$this->setAdminUser();
// Set admin config setting so that activities are not restored by default.
@ -956,7 +1033,7 @@ class course_test extends \advanced_testcase {
}
public function test_restore_invalid_file() {
$this->resetAfterTest();
$this->initialise_test();
// Restore from a non-existing file should not be allowed.
$mode = tool_uploadcourse_processor::MODE_CREATE_NEW;
@ -983,7 +1060,7 @@ class course_test extends \advanced_testcase {
}
public function test_restore_invalid_course() {
$this->resetAfterTest();
$this->initialise_test();
// Restore from an invalid file should not be allowed.
$mode = tool_uploadcourse_processor::MODE_CREATE_NEW;
@ -1000,7 +1077,7 @@ class course_test extends \advanced_testcase {
*/
public function test_reset() {
global $DB;
$this->resetAfterTest(true);
$this->initialise_test();
$c1 = $this->getDataGenerator()->create_course();
$c1ctx = \context_course::instance($c1->id);
@ -1092,7 +1169,7 @@ class course_test extends \advanced_testcase {
public function test_create_bad_category() {
global $DB;
$this->resetAfterTest(true);
$this->initialise_test();
// Ensure fails when category cannot be resolved upon creation.
$mode = tool_uploadcourse_processor::MODE_CREATE_NEW;
@ -1146,7 +1223,7 @@ class course_test extends \advanced_testcase {
}
public function test_enrolment_data() {
$this->resetAfterTest(true);
$this->initialise_test();
// We need to set the current user as one with the capability to edit manual enrolment instances in the new course.
$this->setAdminUser();
@ -1425,7 +1502,7 @@ class course_test extends \advanced_testcase {
}
public function test_idnumber_problems() {
$this->resetAfterTest(true);
$this->initialise_test();
$c1 = $this->getDataGenerator()->create_course(array('shortname' => 'sntaken', 'idnumber' => 'taken'));
$c2 = $this->getDataGenerator()->create_course();
@ -1476,7 +1553,7 @@ class course_test extends \advanced_testcase {
}
public function test_generate_shortname() {
$this->resetAfterTest(true);
$this->initialise_test();
$c1 = $this->getDataGenerator()->create_course(array('shortname' => 'taken'));
@ -1529,7 +1606,7 @@ class course_test extends \advanced_testcase {
public function test_mess_with_frontpage() {
global $SITE;
$this->resetAfterTest(true);
$this->initialise_test();
// Updating the front page.
$mode = tool_uploadcourse_processor::MODE_UPDATE_ONLY;

View file

@ -0,0 +1,6 @@
shortname,fullname,summary,idnumber,category_idnumber
C1,Course 1,Summary 1,ID1,CAT2
C2,Course 2,Summary 2,ID2,CAT2
C3,Course 3,Summary 3,ID3,
C4,Course 4,Summary 4,ID4,CAT1
C5,Course 5,Summary 5,ID5,CAT3
1 shortname fullname summary idnumber category_idnumber
2 C1 Course 1 Summary 1 ID1 CAT2
3 C2 Course 2 Summary 2 ID2 CAT2
4 C3 Course 3 Summary 3 ID3
5 C4 Course 4 Summary 4 ID4 CAT1
6 C5 Course 5 Summary 5 ID5 CAT3

View file

@ -0,0 +1,6 @@
shortname,category_idnumber
C01,CAT2
C02,CAT2
C03,
C04,CAT1
C05,CAT3
1 shortname category_idnumber
2 C01 CAT2
3 C02 CAT2
4 C03
5 C04 CAT1
6 C05 CAT3

View file

@ -381,12 +381,15 @@ class helper_test extends \advanced_testcase {
$c1 = $this->getDataGenerator()->create_category(array('idnumber' => 'C1'));
$c2 = $this->getDataGenerator()->create_category(array('idnumber' => 'C2'));
$c3 = $this->getDataGenerator()->create_category(['idnumber' => 'C3', 'visible' => false]);
// Doubled for cache check.
$this->assertEquals($c1->id, tool_uploadcourse_helper::resolve_category_by_idnumber('C1'));
$this->assertEquals($c1->id, tool_uploadcourse_helper::resolve_category_by_idnumber('C1'));
$this->assertEquals($c2->id, tool_uploadcourse_helper::resolve_category_by_idnumber('C2'));
$this->assertEquals($c2->id, tool_uploadcourse_helper::resolve_category_by_idnumber('C2'));
$this->assertEmpty(tool_uploadcourse_helper::resolve_category_by_idnumber('C3'));
$this->assertEmpty(tool_uploadcourse_helper::resolve_category_by_idnumber('C3'));
$this->assertEmpty(tool_uploadcourse_helper::resolve_category_by_idnumber('DoesNotExist'));
$this->assertEmpty(tool_uploadcourse_helper::resolve_category_by_idnumber('DoesNotExist'));
}
@ -436,8 +439,8 @@ class helper_test extends \advanced_testcase {
// Hidden parent.
$path = array('Cat 2', 'Cat 2.1', 'Cat 2.1.2');
$this->assertEquals($cat2_1_2->id, tool_uploadcourse_helper::resolve_category_by_path($path));
$this->assertEquals($cat2_1_2->id, tool_uploadcourse_helper::resolve_category_by_path($path));
$this->assertEmpty(tool_uploadcourse_helper::resolve_category_by_path($path));
$this->assertEmpty(tool_uploadcourse_helper::resolve_category_by_path($path));
// Does not exist.
$path = array('No cat 3', 'Cat 1.2');

View file

@ -35,6 +35,7 @@ class processor_test extends \advanced_testcase {
public function test_basic() {
global $DB;
$this->resetAfterTest(true);
$this->setAdminUser();
$content = array(
"shortname,fullname,summary",
@ -134,6 +135,7 @@ class processor_test extends \advanced_testcase {
public function test_shortname_template() {
global $DB;
$this->resetAfterTest(true);
$this->setAdminUser();
$content = array(
"shortname,fullname,summary,idnumber",

View file

@ -24,6 +24,6 @@
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2023100900; // The current plugin version (Date: YYYYMMDDXX).
$plugin->version = 2023112800; // The current plugin version (Date: YYYYMMDDXX).
$plugin->requires = 2023100400; // Requires this Moodle version.
$plugin->component = 'tool_uploadcourse'; // Full name of the plugin (used for diagnostics).

View file

@ -620,15 +620,19 @@ class enrol_cohort_plugin extends enrol_plugin {
* @return lang_string|null Error
*/
public function validate_plugin_data_context(array $enrolmentdata, ?int $courseid = null) : ?lang_string {
$error = null;
if (isset($enrolmentdata['customint1'])) {
$cohortid = $enrolmentdata['customint1'];
$coursecontext = \context_course::instance($courseid);
if (!cohort_get_cohort($cohortid, $coursecontext)) {
$error = new lang_string('contextcohortnotallowed', 'cohort', $enrolmentdata['cohortidnumber']);
return new lang_string('contextcohortnotallowed', 'cohort', $enrolmentdata['cohortidnumber']);
}
}
return $error;
$enrolmentdata += [
'customint1' => null,
'customint2' => null,
'roleid' => 0,
];
return parent::validate_plugin_data_context($enrolmentdata, $courseid);
}
/**

View file

@ -246,7 +246,11 @@ class lib_test extends \advanced_testcase {
$enrolmentdata = [
'customint1' => $cohort1->id,
'cohortidnumber' => $cohort1->idnumber,
'courseid' => $course->id,
'id' => null,
'status' => ENROL_INSTANCE_ENABLED,
];
$enrolmentdata = $cohortplugin->fill_enrol_custom_fields($enrolmentdata, $course->id);
$error = $cohortplugin->validate_plugin_data_context($enrolmentdata, $course->id);
$this->assertNull($error);
}
@ -313,6 +317,7 @@ class lib_test extends \advanced_testcase {
*/
public function test_validate_enrol_plugin_data() {
$this->resetAfterTest();
$this->setAdminUser();
$cat = $this->getDataGenerator()->create_category();
$cat1 = $this->getDataGenerator()->create_category(['parent' => $cat->id]);

View file

@ -519,6 +519,16 @@ class enrol_guest_plugin extends enrol_plugin {
return $instance;
}
/**
* Fill custom fields data for a given enrolment plugin.
*
* @param array $enrolmentdata enrolment data.
* @param int $courseid Course ID.
* @return array Updated enrolment data with custom fields info.
*/
public function fill_enrol_custom_fields(array $enrolmentdata, int $courseid): array {
return $enrolmentdata + ['password' => ''];
}
}
/**

View file

@ -675,6 +675,20 @@ class enrol_manual_plugin extends enrol_plugin {
}
return $instance;
}
/**
* Fill custom fields data for a given enrolment plugin.
*
* @param array $enrolmentdata enrolment data.
* @param int $courseid Course ID.
* @return array Updated enrolment data with custom fields info.
*/
public function fill_enrol_custom_fields(array $enrolmentdata, int $courseid): array {
return $enrolmentdata + [
'expirynotify' => 0,
'expirythreshold' => 0,
];
}
}
/**

View file

@ -468,7 +468,10 @@ class enrol_meta_plugin extends enrol_plugin {
} else if (isset($enrolmentdata['groupname'])) {
$enrolmentdata['customint2'] = groups_get_group_by_name($courseid, $enrolmentdata['groupname']);
}
return $enrolmentdata;
return $enrolmentdata + [
'customint1' => null,
'customint2' => null,
];
}
/**

View file

@ -1108,13 +1108,13 @@ class plugin_test extends \advanced_testcase {
$enrolmentdata = $metaplugin->fill_enrol_custom_fields($enrolmentdata, $course1->id);
$this->assertArrayHasKey('customint1', $enrolmentdata);
$this->assertEquals($course2->id, $enrolmentdata['customint1']);
$this->assertArrayNotHasKey('customint2', $enrolmentdata);
$this->assertNull($enrolmentdata['customint2']);
$enrolmentdata['metacoursename'] = 'notexist';
$enrolmentdata = $metaplugin->fill_enrol_custom_fields($enrolmentdata, $course1->id);
$this->assertArrayHasKey('customint1', $enrolmentdata);
$this->assertFalse($enrolmentdata['customint1']);
$this->assertArrayNotHasKey('customint2', $enrolmentdata);
$this->assertNull($enrolmentdata['customint2']);
$enrolmentdata['metacoursename'] = $course2->shortname;

View file

@ -1110,6 +1110,16 @@ class enrol_self_plugin extends enrol_plugin {
return $instance;
}
/**
* Fill custom fields data for a given enrolment plugin.
*
* @param array $enrolmentdata enrolment data.
* @param int $courseid Course ID.
* @return array Updated enrolment data with custom fields info.
*/
public function fill_enrol_custom_fields(array $enrolmentdata, int $courseid): array {
return $enrolmentdata + ['password' => ''];
}
}
/**

View file

@ -2707,6 +2707,9 @@ abstract class enrol_plugin {
/**
* Check if enrolment plugin is supported in csv course upload.
*
* If supported, plugins are also encouraged to override methods:
* {@see self::fill_enrol_custom_fields()}, {@see self::validate_plugin_data_context()}
*
* @return bool
*/
public function is_csv_upload_supported(): bool {
@ -3485,7 +3488,10 @@ abstract class enrol_plugin {
/**
* Fill custom fields data for a given enrolment plugin.
*
* @param array $enrolmentdata enrolment data.
* For example: resolve linked entities from the idnumbers (cohort, role, group, etc.)
* Also fill the default values that are not specified.
*
* @param array $enrolmentdata enrolment data received in CSV file in tool_uploadcourse
* @param int $courseid Course ID.
* @return array Updated enrolment data with custom fields info.
*/
@ -3514,11 +3520,31 @@ abstract class enrol_plugin {
/**
* Check if plugin custom data is allowed in relevant context.
*
* This is called from the tool_uploadcourse if the plugin supports instance creation in
* upload course ({@see self::is_csv_upload_supported()})
*
* The fallback is to call the edit_instance_validation() but it will be better if the plugins
* implement this method to return better error messages.
*
* @param array $enrolmentdata enrolment data to validate.
* @param int|null $courseid Course ID.
* @return lang_string|null Error
*/
public function validate_plugin_data_context(array $enrolmentdata, ?int $courseid = null) : ?lang_string {
if ($courseid) {
$enrolmentdata += ['courseid' => $courseid, 'id' => 0, 'status' => ENROL_INSTANCE_ENABLED];
$instance = (object)[
'id' => null,
'courseid' => $courseid,
'status' => $enrolmentdata['status'],
'type' => $this->get_name(),
];
$formerrors = $this->edit_instance_validation($enrolmentdata, [], $instance, context_course::instance($courseid));
if (!empty($formerrors)) {
$errors = array_map(fn($key) => "{$key}: {$formerrors[$key]}", array_keys($formerrors));
return new lang_string('errorcannotcreateorupdateenrolment', 'tool_uploadcourse', $errors);
}
}
return null;
}