From 5076b961712efe25c5c0b0e54b2c490f49546b27 Mon Sep 17 00:00:00 2001 From: Marina Glancy Date: Thu, 24 Jul 2014 16:27:09 +0800 Subject: [PATCH] MDL-35468 cohorts: upload cohorts from csv file --- cohort/index.php | 7 +- cohort/lib.php | 6 + cohort/tests/behat/upload_cohorts.feature | 156 ++++++ cohort/tests/fixtures/uploadcohorts1.csv | 7 + cohort/tests/fixtures/uploadcohorts2.csv | 8 + cohort/tests/fixtures/uploadcohorts3.csv | 6 + cohort/tests/fixtures/uploadcohorts_test.csv | 13 + cohort/upload.php | 93 ++++ cohort/upload_form.php | 552 +++++++++++++++++++ lang/en/cohort.php | 20 + 10 files changed, 864 insertions(+), 4 deletions(-) create mode 100644 cohort/tests/behat/upload_cohorts.feature create mode 100644 cohort/tests/fixtures/uploadcohorts1.csv create mode 100644 cohort/tests/fixtures/uploadcohorts2.csv create mode 100644 cohort/tests/fixtures/uploadcohorts3.csv create mode 100644 cohort/tests/fixtures/uploadcohorts_test.csv create mode 100644 cohort/upload.php create mode 100644 cohort/upload_form.php diff --git a/cohort/index.php b/cohort/index.php index 796dd03d165fb..f139e23b34e8f 100644 --- a/cohort/index.php +++ b/cohort/index.php @@ -25,7 +25,6 @@ require('../config.php'); require($CFG->dirroot.'/cohort/lib.php'); require_once($CFG->libdir.'/adminlib.php'); -require_once($CFG->libdir.'/coursecatlib.php'); $contextid = optional_param('contextid', 0, PARAM_INT); $page = optional_param('page', 0, PARAM_INT); @@ -124,10 +123,10 @@ $cohortcontext = context::instance_by_id($cohort->contextid); if ($showall) { if ($cohortcontext->contextlevel == CONTEXT_COURSECAT) { - $cat = coursecat::get($cohortcontext->instanceid); - $line[] = html_writer::link(new moodle_url('/cohort/index.php' , array('contextid' => $cohort->contextid)), $cat->get_formatted_name()); + $line[] = html_writer::link(new moodle_url('/cohort/index.php' , + array('contextid' => $cohort->contextid)), $cohortcontext->get_context_name(false)); } else { - $line[] = get_string('coresystem'); + $line[] = $cohortcontext->get_context_name(false); } } $line[] = format_string($cohort->name); diff --git a/cohort/lib.php b/cohort/lib.php index 0110e9a9a8294..74d1553010808 100644 --- a/cohort/lib.php +++ b/cohort/lib.php @@ -418,6 +418,12 @@ function cohort_edit_controls(context $context, moodle_url $currenturl) { if ($currenturl->get_path() === $addurl->get_path() && !$currenturl->param('id')) { $currenttab = 'addcohort'; } + + $uploadurl = new moodle_url('/cohort/upload.php', array('contextid' => $context->id)); + $tabs[] = new tabobject('uploadcohorts', $uploadurl, get_string('uploadcohorts', 'cohort')); + if ($currenturl->get_path() === $uploadurl->get_path()) { + $currenttab = 'uploadcohorts'; + } } if (count($tabs) > 1) { return new tabtree($tabs, $currenttab); diff --git a/cohort/tests/behat/upload_cohorts.feature b/cohort/tests/behat/upload_cohorts.feature new file mode 100644 index 0000000000000..2ca6592e1a2c6 --- /dev/null +++ b/cohort/tests/behat/upload_cohorts.feature @@ -0,0 +1,156 @@ +@core @core_cohort @_file_upload +Feature: A privileged user can create cohorts using a CSV file + In order to create cohorts using a CSV file + As an admin + I need to be able to upload a CSV file and navigate through the upload process + + Background: + Given the following "categories" exist: + | name | category | idnumber | + | Cat 1 | 0 | CAT1 | + | Cat 2 | 0 | CAT2 | + | Cat 3 | CAT1 | CAT3 | + + @javascript + Scenario: Upload cohorts with default System context as admin + When I log in as "admin" + And I navigate to "Cohorts" node in "Site administration > Users > Accounts" + And I follow "Upload cohorts" + And I upload "cohort/tests/fixtures/uploadcohorts1.csv" file to "File" filemanager + And I click on "Preview" "button" + Then the following should exist in the "previewuploadedcohorts" table: + | name | idnumber | description | Context | Status | + | cohort name 1 | cohortid1 | first description | System | | + | cohort name 2 | cohortid2 | | System | | + | cohort name 3 | cohortid3 | | Miscellaneous | | + | cohort name 4 | cohortid4 | | Cat 1 | | + | cohort name 5 | cohortid5 | | Cat 2 | | + | cohort name 6 | cohortid6 | | Cat 3 | | + And I press "Upload cohorts" + And I should see "Uploaded 6 cohorts" + And I press "Continue" + And the following should exist in the "cohorts" table: + | Name | Cohort ID | Description | Cohort size | Source | + | cohort name 1 | cohortid1 | first description | 0 | Created manually | + | cohort name 2 | cohortid2 | | 0 | Created manually | + And I follow "All cohorts" + And the following should exist in the "cohorts" table: + | Category | Name | Cohort ID | Description | Cohort size | Source | + | System | cohort name 1 | cohortid1 | first description | 0 | Created manually | + | System | cohort name 2 | cohortid2 | | 0 | Created manually | + | Miscellaneous | cohort name 3 | cohortid3 | | 0 | Created manually | + | Cat 1 | cohort name 4 | cohortid4 | | 0 | Created manually | + | Cat 2 | cohort name 5 | cohortid5 | | 0 | Created manually | + | Cat 3 | cohort name 6 | cohortid6 | | 0 | Created manually | + + @javascript + Scenario: Upload cohorts with default category context as admin + When I log in as "admin" + And I navigate to "Cohorts" node in "Site administration > Users > Accounts" + And I follow "Upload cohorts" + And I upload "cohort/tests/fixtures/uploadcohorts1.csv" file to "File" filemanager + And I set the field "Default context" to "Cat 1 / Cat 3" + And I click on "Preview" "button" + Then the following should exist in the "previewuploadedcohorts" table: + | name | idnumber | description | Context | Status | + | cohort name 1 | cohortid1 | first description | Cat 3 | | + | cohort name 2 | cohortid2 | | Cat 3 | | + | cohort name 3 | cohortid3 | | Miscellaneous | | + | cohort name 4 | cohortid4 | | Cat 1 | | + | cohort name 5 | cohortid5 | | Cat 2 | | + | cohort name 6 | cohortid6 | | Cat 3 | | + And I press "Upload cohorts" + And I should see "Uploaded 6 cohorts" + And I press "Continue" + And I should see "Category: Cat 3: available cohorts (3)" + And I navigate to "Cohorts" node in "Site administration > Users > Accounts" + And I follow "All cohorts" + And the following should exist in the "cohorts" table: + | Category | Name | Cohort ID | Description | Cohort size | Source | + | Cat 3 | cohort name 1 | cohortid1 | first description | 0 | Created manually | + | Cat 3 | cohort name 2 | cohortid2 | | 0 | Created manually | + | Miscellaneous | cohort name 3 | cohortid3 | | 0 | Created manually | + | Cat 1 | cohort name 4 | cohortid4 | | 0 | Created manually | + | Cat 2 | cohort name 5 | cohortid5 | | 0 | Created manually | + | Cat 3 | cohort name 6 | cohortid6 | | 0 | Created manually | + + @javascript + Scenario: Upload cohorts with default category context as manager + Given the following "users" exist: + | username | firstname | lastname | email | + | user1 | User | 1 | user1@moodlemoodle.com | + And the following "role assigns" exist: + | user | role | contextlevel | reference | + | user1 | manager | Category | CAT1 | + When I log in as "user1" + And I follow "Courses" + And I follow "Cat 1" + And I navigate to "Cohorts" node in "Category: Cat 1" + And I follow "Upload cohorts" + And I upload "cohort/tests/fixtures/uploadcohorts1.csv" file to "File" filemanager + And I click on "Preview" "button" + Then the following should exist in the "previewuploadedcohorts" table: + | name | idnumber | description | Context | Status | + | cohort name 1 | cohortid1 | first description | Cat 1 | | + | cohort name 2 | cohortid2 | | Cat 1 | | + | cohort name 3 | cohortid3 | | Cat 1 | Category Miscellaneous not found or you don't have permission to create a cohort there. The default context will be used. | + | cohort name 4 | cohortid4 | | Cat 1 | | + | cohort name 5 | cohortid5 | | Cat 1 | Category CAT2 not found or you don't have permission to create a cohort there. The default context will be used. | + | cohort name 6 | cohortid6 | | Cat 3 | | + And I press "Upload cohorts" + And I should see "Uploaded 6 cohorts" + + @javascript + Scenario: Upload cohorts with conflicting id number + Given the following "cohorts" exist: + | name | idnumber | + | Cohort | cohortid2 | + When I log in as "admin" + And I navigate to "Cohorts" node in "Site administration > Users > Accounts" + And I follow "Upload cohorts" + And I upload "cohort/tests/fixtures/uploadcohorts1.csv" file to "File" filemanager + And I click on "Preview" "button" + Then I should see "Errors were found in CSV data. See details below." + Then the following should exist in the "previewuploadedcohorts" table: + | name | idnumber | description | Context | Status | + | cohort name 1 | cohortid1 | first description | System | | + | cohort name 2 | cohortid2 | | System | Cohort with the same ID number already exists | + | cohort name 3 | cohortid3 | | Miscellaneous | | + | cohort name 4 | cohortid4 | | Cat 1 | | + | cohort name 5 | cohortid5 | | Cat 2 | | + | cohort name 6 | cohortid6 | | Cat 3 | | + And "Upload cohorts" "button" should not exist + + @javascript + Scenario: Upload cohorts with different ways of specifying context + When I log in as "admin" + And I navigate to "Cohorts" node in "Site administration > Users > Accounts" + And I follow "Upload cohorts" + And I upload "cohort/tests/fixtures/uploadcohorts2.csv" file to "File" filemanager + And I click on "Preview" "button" + Then the following should exist in the "previewuploadedcohorts" table: + | name | idnumber | description | Context | Status | + | Specify category as name | cohortid1 | | Miscellaneous | | + | Specify category as idnumber | cohortid2 | | Cat 1 | | + | Specify category as id | cohortid3 | | Miscellaneous | | + | Specify category as path | cohortid4 | | Cat 3 | | + | Specify category_id | cohortid5 | | Miscellaneous | | + | Specify category_idnumber | cohortid6 | | Cat 1 | | + | Specify category_path | cohortid7 | | Cat 3 | | + And I should not see "not found or you" + And I press "Upload cohorts" + And I should see "Uploaded 7 cohorts" + And I press "Continue" + And I follow "Upload cohorts" + And I upload "cohort/tests/fixtures/uploadcohorts3.csv" file to "File" filemanager + And I click on "Preview" "button" + And the following should exist in the "previewuploadedcohorts" table: + | name | idnumber | description | Context | Status | + | Specify context as id (system) | cohortid8 | | System | | + | Specify context as name (system) | cohortid9 | | System | | + | Specify context as category name only | cohortid10 | | Cat 1 | | + | Specify context as category path | cohortid12 | | Cat 3 | | + | Specify context as category idnumber | cohortid13 | | Cat 2 | | + And I should not see "not found or you" + And I press "Upload cohorts" + And I should see "Uploaded 5 cohorts" diff --git a/cohort/tests/fixtures/uploadcohorts1.csv b/cohort/tests/fixtures/uploadcohorts1.csv new file mode 100644 index 0000000000000..b9ed12ffcc937 --- /dev/null +++ b/cohort/tests/fixtures/uploadcohorts1.csv @@ -0,0 +1,7 @@ +name,idnumber,description,category +cohort name 1,cohortid1,first description, +cohort name 2,cohortid2,, +cohort name 3,cohortid3,,Miscellaneous +cohort name 4,cohortid4,,CAT1 +cohort name 5,cohortid5,,CAT2 +cohort name 6,cohortid6,,CAT3 diff --git a/cohort/tests/fixtures/uploadcohorts2.csv b/cohort/tests/fixtures/uploadcohorts2.csv new file mode 100644 index 0000000000000..f2fd9f3e1cebe --- /dev/null +++ b/cohort/tests/fixtures/uploadcohorts2.csv @@ -0,0 +1,8 @@ +name,idnumber,description,category,category_id,category_idnumber,category_path +Specify category as name,cohortid1,,Miscellaneous,,, +Specify category as idnumber,cohortid2,,CAT1,,, +Specify category as id,cohortid3,,1,,, +Specify category as path,cohortid4,,Cat 1 / Cat 3,,, +Specify category_id,cohortid5,,,1,, +Specify category_idnumber,cohortid6,,,,CAT1, +Specify category_path,cohortid7,,,,,Cat 1 / Cat 3 diff --git a/cohort/tests/fixtures/uploadcohorts3.csv b/cohort/tests/fixtures/uploadcohorts3.csv new file mode 100644 index 0000000000000..9c6f40cc1e6eb --- /dev/null +++ b/cohort/tests/fixtures/uploadcohorts3.csv @@ -0,0 +1,6 @@ +name,idnumber,description,context +Specify context as id (system),cohortid8,,1 +Specify context as name (system),cohortid9,,System +Specify context as category name only,cohortid10,,Cat 1 +Specify context as category path,cohortid12,,Cat 1 / Cat 3 +Specify context as category idnumber,cohortid13,,CAT2 diff --git a/cohort/tests/fixtures/uploadcohorts_test.csv b/cohort/tests/fixtures/uploadcohorts_test.csv new file mode 100644 index 0000000000000..567732c790571 --- /dev/null +++ b/cohort/tests/fixtures/uploadcohorts_test.csv @@ -0,0 +1,13 @@ +name,idnumber,description,category +cohort name 1,cid1,first description, +cohort name 2,cid2,, +cohort name 3,cid3,,Miscellaneous +cohort name 4,cid4,,CAT1 +cohort name 5,cid5,,CAT2 +cohort name 6,cid6,,CAT3 +cohort name 7,cid7,,CAT1 +cohort name 8,cid8,,CAT2 +cohort name 9,cid9,,CAT3 +cohort name 10,cid10,,CAT1 +cohort name 11,cid11,,CAT2 +cohort name 12,cid1,,CAT3 diff --git a/cohort/upload.php b/cohort/upload.php new file mode 100644 index 0000000000000..d83b2ee3fba16 --- /dev/null +++ b/cohort/upload.php @@ -0,0 +1,93 @@ +. + +/** + * A form for cohort upload. + * + * @package core_cohort + * @copyright 2014 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once('../config.php'); +require_once($CFG->dirroot.'/cohort/lib.php'); +require_once($CFG->dirroot.'/cohort/upload_form.php'); +require_once($CFG->libdir . '/csvlib.class.php'); + +$contextid = optional_param('contextid', 0, PARAM_INT); +$returnurl = optional_param('returnurl', '', PARAM_URL); + +require_login(); + +if ($contextid) { + $context = context::instance_by_id($contextid, MUST_EXIST); +} else { + $context = context_system::instance(); +} +if ($context->contextlevel != CONTEXT_COURSECAT && $context->contextlevel != CONTEXT_SYSTEM) { + print_error('invalidcontext'); +} + +require_capability('moodle/cohort:manage', $context); + +$PAGE->set_context($context); +$baseurl = new moodle_url('/cohort/upload.php', array('contextid' => $context->id)); +$PAGE->set_url($baseurl); +$PAGE->set_heading($COURSE->fullname); +$PAGE->set_pagelayout('admin'); + +if ($context->contextlevel == CONTEXT_COURSECAT) { + $PAGE->set_category_by_id($context->instanceid); + navigation_node::override_active_url(new moodle_url('/cohort/index.php', array('contextid' => $context->id))); +} else { + navigation_node::override_active_url(new moodle_url('/cohort/index.php', array())); +} + +$uploadform = new cohort_upload_form(null, array('contextid' => $context->id, 'returnurl' => $returnurl)); + +if ($returnurl) { + $returnurl = new moodle_url($returnurl); +} else { + $returnurl = new moodle_url('/cohort/index.php', array('contextid' => $context->id)); +} + +if ($uploadform->is_cancelled()) { + redirect($returnurl); +} + +$strheading = get_string('uploadcohorts', 'cohort'); +$PAGE->navbar->add($strheading); + +echo $OUTPUT->header(); +echo $OUTPUT->heading_with_help($strheading, 'uploadcohorts', 'cohort'); + +if ($editcontrols = cohort_edit_controls($context, $baseurl)) { + echo $OUTPUT->render($editcontrols); +} + +if ($data = $uploadform->get_data()) { + $cohortsdata = $uploadform->get_cohorts_data(); + foreach ($cohortsdata as $cohort) { + cohort_add_cohort($cohort); + } + echo $OUTPUT->notification(get_string('uploadedcohorts', 'cohort', count($cohortsdata)), 'notifysuccess'); + echo $OUTPUT->continue_button($returnurl); +} else { + $uploadform->display(); +} + +echo $OUTPUT->footer(); + diff --git a/cohort/upload_form.php b/cohort/upload_form.php new file mode 100644 index 0000000000000..fa041aaa5df31 --- /dev/null +++ b/cohort/upload_form.php @@ -0,0 +1,552 @@ +. + +/** + * A form for cohort upload. + * + * @package core_cohort + * @copyright 2014 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir.'/formslib.php'); + +/** + * Cohort upload form class + * + * @package core_cohort + * @copyright 2014 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cohort_upload_form extends moodleform { + /** @var array new cohorts that need to be created */ + public $processeddata = null; + /** @var array cached list of available contexts */ + protected $contextoptions = null; + /** @var array temporary cache for retrieved categories */ + protected $categoriescache = array(); + + /** + * Form definition + */ + public function definition() { + $mform = $this->_form; + $data = (object)$this->_customdata; + + $mform->addElement('hidden', 'returnurl'); + $mform->setType('returnurl', PARAM_URL); + + $mform->addElement('header', 'cohortfileuploadform', get_string('uploadafile')); + + $filepickeroptions = array(); + $filepickeroptions['filetypes'] = '*'; + $filepickeroptions['maxbytes'] = get_max_upload_file_size(); + $mform->addElement('filepicker', 'cohortfile', get_string('file'), null, $filepickeroptions); + + $choices = csv_import_reader::get_delimiter_list(); + $mform->addElement('select', 'delimiter', get_string('csvdelimiter', 'tool_uploadcourse'), $choices); + if (array_key_exists('cfg', $choices)) { + $mform->setDefault('delimiter', 'cfg'); + } else if (get_string('listsep', 'langconfig') == ';') { + $mform->setDefault('delimiter', 'semicolon'); + } else { + $mform->setDefault('delimiter', 'comma'); + } + $mform->addHelpButton('delimiter', 'csvdelimiter', 'tool_uploadcourse'); + + $choices = core_text::get_encodings(); + $mform->addElement('select', 'encoding', get_string('encoding', 'tool_uploadcourse'), $choices); + $mform->setDefault('encoding', 'UTF-8'); + $mform->addHelpButton('encoding', 'encoding', 'tool_uploadcourse'); + + $options = $this->get_context_options(); + $mform->addElement('select', 'contextid', get_string('defaultcontext', 'cohort'), $options); + + $this->add_cohort_upload_buttons(true); + $this->set_data($data); + } + + /** + * Add buttons to the form ("Upload cohorts", "Preview", "Cancel") + */ + protected function add_cohort_upload_buttons() { + $mform = $this->_form; + + $buttonarray = array(); + + $submitlabel = get_string('uploadcohorts', 'cohort'); + $buttonarray[] = $mform->createElement('submit', 'submitbutton', $submitlabel); + + $previewlabel = get_string('preview', 'cohort'); + $buttonarray[] = $mform->createElement('submit', 'previewbutton', $previewlabel); + $mform->registerNoSubmitButton('previewbutton'); + + $buttonarray[] = $mform->createElement('cancel'); + + $mform->addGroup($buttonarray, 'buttonar', '', array(' '), false); + $mform->closeHeaderBefore('buttonar'); + } + + /** + * Process the uploaded file and allow the submit button only if it doest not have errors. + */ + public function definition_after_data() { + $mform = $this->_form; + $cohortfile = $mform->getElementValue('cohortfile'); + $allowsubmitform = false; + if ($cohortfile && ($file = $this->get_cohort_file($cohortfile))) { + // File was uploaded. Parse it. + $encoding = $mform->getElementValue('encoding')[0]; + $delimiter = $mform->getElementValue('delimiter')[0]; + $contextid = $mform->getElementValue('contextid')[0]; + if (!empty($contextid) && ($context = context::instance_by_id($contextid, IGNORE_MISSING))) { + $this->processeddata = $this->process_upload_file($file, $encoding, $delimiter, $context); + if ($this->processeddata && count($this->processeddata) > 1 && !$this->processeddata[0]['errors']) { + $allowsubmitform = true; + } + } + } + if (!$allowsubmitform) { + // Hide submit button. + $el = $mform->getElement('buttonar')->getElements()[0]; + $el->setValue(''); + $el->freeze(); + } else { + $mform->setExpanded('cohortfileuploadform', false); + } + + } + + /** + * Returns the list of contexts where current user can create cohorts. + * + * @return array + */ + protected function get_context_options() { + global $CFG; + require_once($CFG->libdir. '/coursecatlib.php'); + if ($this->contextoptions === null) { + $this->contextoptions = array(); + $displaylist = coursecat::make_categories_list('moodle/cohort:manage'); + // We need to index the options array by context id instead of category id and add option for system context. + $syscontext = context_system::instance(); + if (has_capability('moodle/cohort:manage', $syscontext)) { + $this->contextoptions[$syscontext->id] = $syscontext->get_context_name(); + } + foreach ($displaylist as $cid => $name) { + $context = context_coursecat::instance($cid); + $this->contextoptions[$context->id] = $name; + } + } + return $this->contextoptions; + } + + public function validation($data, $files) { + $errors = parent::validation($data, $files); + if (empty($errors)) { + if (empty($data['cohortfile']) || !($file = $this->get_cohort_file($data['cohortfile']))) { + $errors['cohortfile'] = get_string('required'); + } else { + if (!empty($this->processeddata[0]['errors'])) { + // Any value in $errors will notify that validation did not pass. The detailed errors will be shown in preview. + $errors['dummy'] = ''; + } + } + } + return $errors; + } + + /** + * Returns the uploaded file if it is present. + * + * @param int $draftid + * @return stored_file|null + */ + protected function get_cohort_file($draftid) { + global $USER; + // We can not use moodleform::get_file_content() method because we need the content before the form is validated. + if (!$draftid) { + return null; + } + $fs = get_file_storage(); + $context = context_user::instance($USER->id); + if (!$files = $fs->get_area_files($context->id, 'user', 'draft', $draftid, 'id DESC', false)) { + return null; + } + $file = reset($files); + + return $file; + + } + + /** + * Returns the list of prepared objects to be added as cohorts + * + * @return array of stdClass objects, each can be passed to {@link cohort_add_cohort()} + */ + public function get_cohorts_data() { + $cohorts = array(); + if ($this->processeddata) { + foreach ($this->processeddata as $idx => $line) { + if ($idx && !empty($line['data'])) { + $cohorts[] = (object)$line['data']; + } + } + } + return $cohorts; + } + + /** + * Displays the preview of the uploaded file + */ + protected function preview_uploaded_cohorts() { + global $OUTPUT; + if (empty($this->processeddata)) { + return; + } + foreach ($this->processeddata[0]['errors'] as $error) { + echo $OUTPUT->notification($error); + } + foreach ($this->processeddata[0]['warnings'] as $warning) { + echo $OUTPUT->notification($warning, 'notifymessage'); + } + $table = new html_table(); + $table->id = 'previewuploadedcohorts'; + $columns = $this->processeddata[0]['data']; + $columns['contextid'] = get_string('context', 'role'); + + // Add column names to the preview table. + $table->head = array(''); + foreach ($columns as $key => $value) { + $table->head[] = $value; + } + $table->head[] = get_string('status'); + + // Add (some) rows to the preview table. + $previewdrows = $this->get_previewed_rows(); + foreach ($previewdrows as $idx) { + $line = $this->processeddata[$idx]; + $cells = array(new html_table_cell($idx)); + $context = context::instance_by_id($line['data']['contextid']); + foreach ($columns as $key => $value) { + if ($key === 'contextid') { + $text = html_writer::link(new moodle_url('/cohort/index.php', array('contextid' => $context->id)), + $context->get_context_name(false)); + } else { + $text = s($line['data'][$key]); + } + $cells[] = new html_table_cell($text); + } + $text = ''; + if ($line['errors']) { + $text .= html_writer::div(join('
', $line['errors']), 'notifyproblem'); + } + if ($line['warnings']) { + $text .= html_writer::div(join('
', $line['warnings'])); + } + $cells[] = new html_table_cell($text); + $table->data[] = new html_table_row($cells); + } + if ($notdisplayed = count($this->processeddata) - count($previewdrows) - 1) { + $cell = new html_table_cell(get_string('displayedrows', 'cohort', + (object)array('displayed' => count($previewdrows), 'total' => count($this->processeddata) - 1))); + $cell->colspan = count($columns) + 2; + $table->data[] = new html_table_row(array($cell)); + } + echo html_writer::table($table); + } + + /** + * Find up rows to show in preview + * + * Number of previewed rows is limited but rows with errors and warnings have priority. + * + * @return array + */ + protected function get_previewed_rows() { + $previewlimit = 10; + if (count($this->processeddata) <= 1) { + $rows = array(); + } else if (count($this->processeddata) < $previewlimit + 1) { + // Return all rows. + $rows = range(1, count($this->processeddata) - 1); + } else { + // First find rows with errors and warnings (no more than 10 of each). + $errorrows = $warningrows = array(); + foreach ($this->processeddata as $rownum => $line) { + if ($rownum && $line['errors']) { + $errorrows[] = $rownum; + if (count($errorrows) >= $previewlimit) { + return $errorrows; + } + } else if ($rownum && $line['warnings']) { + if (count($warningrows) + count($errorrows) < $previewlimit) { + $warningrows[] = $rownum; + } + } + } + // Include as many error rows as possible and top them up with warning rows. + $rows = array_merge($errorrows, array_slice($warningrows, 0, $previewlimit - count($errorrows))); + // Keep adding good rows until we reach limit. + for ($rownum = 1; count($rows) < $previewlimit; $rownum++) { + if (!in_array($rownum, $rows)) { + $rows[] = $rownum; + } + } + asort($rows); + } + return $rows; + } + + public function display() { + // Finalize the form definition if not yet done. + if (!$this->_definition_finalized) { + $this->_definition_finalized = true; + $this->definition_after_data(); + } + + // Difference from the parent display() method is that we want to show preview above the form if applicable. + $this->preview_uploaded_cohorts(); + + $this->_form->display(); + } + + /** + * @param stored_file $file + * @param string $encoding + * @param string $delimiter + * @param context $defaultcontext + * @return array + */ + protected function process_upload_file($file, $encoding, $delimiter, $defaultcontext) { + global $CFG, $DB; + require_once($CFG->libdir . '/csvlib.class.php'); + + $cohorts = array( + 0 => array('errors' => array(), 'warnings' => array(), 'data' => array()) + ); + + // Read and parse the CSV file using csv library. + $content = $file->get_content(); + if (!$content) { + $cohorts[0]['errors'][] = new lang_string('csvemptyfile', 'error'); + return $cohorts; + } + + $uploadid = csv_import_reader::get_new_iid('uploadcohort'); + $cir = new csv_import_reader($uploadid, 'uploadcohort'); + $readcount = $cir->load_csv_content($content, $encoding, $delimiter); + unset($content); + if (!$readcount) { + $cohorts[0]['errors'][] = get_string('csvloaderror', 'error', $cir->get_error()); + return $cohorts; + } + $columns = $cir->get_columns(); + + // Check that columns include 'name' and warn about extra columns. + $allowedcolumns = array('contextid', 'name', 'idnumber', 'description', 'descriptionformat'); + $additionalcolumns = array('context', 'category', 'category_id', 'category_idnumber', 'category_path'); + $displaycolumns = array(); + $extracolumns = array(); + $columnsmapping = array(); + foreach ($columns as $i => $columnname) { + $columnnamelower = preg_replace('/ /', '', core_text::strtolower($columnname)); + $columnsmapping[$i] = null; + if (in_array($columnnamelower, $allowedcolumns)) { + $displaycolumns[$columnnamelower] = $columnname; + $columnsmapping[$i] = $columnnamelower; + } else if (in_array($columnnamelower, $additionalcolumns)) { + $columnsmapping[$i] = $columnnamelower; + } else { + $extracolumns[] = $columnname; + } + } + if (!in_array('name', $columnsmapping)) { + $cohorts[0]['errors'][] = new lang_string('namecolumnmissing', 'cohort'); + return $cohorts; + } + if ($extracolumns) { + $cohorts[0]['warnings'][] = new lang_string('csvextracolumns', 'cohort', s(join(', ', $extracolumns))); + } + + if (!isset($displaycolumns['contextid'])) { + $displaycolumns['contextid'] = 'contextid'; + } + $cohorts[0]['data'] = $displaycolumns; + + // Parse data rows. + $cir->init(); + $rownum = 0; + $idnumbers = array(); + $haserrors = false; + $haswarnings = false; + while ($row = $cir->next()) { + $rownum++; + $cohorts[$rownum] = array( + 'errors' => array(), + 'warnings' => array(), + 'data' => array(), + ); + $hash = array(); + foreach ($row as $i => $value) { + if ($columnsmapping[$i]) { + $hash[$columnsmapping[$i]] = $value; + } + } + $this->clean_cohort_data($hash); + + $warnings = $this->resolve_context($hash, $defaultcontext); + $cohorts[$rownum]['warnings'] = array_merge($cohorts[$rownum]['warnings'], $warnings); + + if (!empty($hash['idnumber'])) { + if (isset($idnumbers[$hash['idnumber']]) || $DB->record_exists('cohort', array('idnumber' => $hash['idnumber']))) { + $cohorts[$rownum]['errors'][] = new lang_string('duplicateidnumber', 'cohort'); + } + $idnumbers[$hash['idnumber']] = true; + } + + if (empty($hash['name'])) { + $cohorts[$rownum]['errors'][] = new lang_string('namefieldempty', 'cohort'); + } + + $cohorts[$rownum]['data'] = array_intersect_key($hash, $cohorts[0]['data']); + $haserrors = $haserrors || !empty($cohorts[$rownum]['errors']); + $haswarnings = $haswarnings || !empty($cohorts[$rownum]['warnings']); + } + + if ($haserrors) { + $cohorts[0]['errors'][] = new lang_string('csvcontainserrors', 'cohort'); + } + + if ($haswarnings) { + $cohorts[0]['warnings'][] = new lang_string('csvcontainswarnings', 'cohort'); + } + + return $cohorts; + } + + /** + * Cleans input data about one cohort. + * + * @param array $hash + */ + protected function clean_cohort_data(&$hash) { + foreach ($hash as $key => $value) { + switch ($key) { + case 'contextid': $hash[$key] = clean_param($value, PARAM_INT); break; + case 'name': $hash[$key] = core_text::substr(clean_param($value, PARAM_TEXT), 0, 254); break; + case 'idnumber': $hash[$key] = core_text::substr(clean_param($value, PARAM_RAW), 0, 254); break; + case 'description': $hash[$key] = clean_param($value, PARAM_RAW); break; + case 'descriptionformat': $hash[$key] = clean_param($value, PARAM_INT); break; + } + } + } + + /** + * Determines in which context the particular cohort will be created + * + * @param array $hash + * @param context $defaultcontext + * @return array array of warning strings + */ + protected function resolve_context(&$hash, $defaultcontext) { + global $DB; + + $warnings = array(); + + if (!empty($hash['contextid'])) { + // Contextid was specified, verify we can post there. + $contextoptions = $this->get_context_options(); + if (!isset($contextoptions[$hash['contextid']])) { + $warnings[] = new lang_string('contextnotfound', 'cohort', $hash['contextid']); + $hash['contextid'] = $defaultcontext->id; + } + return $warnings; + } + + if (!empty($hash['context'])) { + $systemcontext = context_system::instance(); + if ((core_text::strtolower(trim($hash['context'])) === + core_text::strtolower($systemcontext->get_context_name())) || + ('' . $hash['context'] === '' . $systemcontext->id)) { + // User meant system context. + $hash['contextid'] = $systemcontext->id; + $contextoptions = $this->get_context_options(); + if (!isset($contextoptions[$hash['contextid']])) { + $warnings[] = new lang_string('contextnotfound', 'cohort', $hash['context']); + $hash['contextid'] = $defaultcontext->id; + } + } else { + // Assume it is a category. + $hash['category'] = trim($hash['context']); + } + } + + if (!empty($hash['category_path'])) { + // We already have array with available categories, look up the value. + $contextoptions = $this->get_context_options(); + if (!$hash['contextid'] = array_search($hash['category_path'], $contextoptions)) { + $warnings[] = new lang_string('categorynotfound', 'cohort', s($hash['category_path'])); + $hash['contextid'] = $defaultcontext->id; + } + return $warnings; + } + + if (!empty($hash['category'])) { + // Quick search by category path first. + // Do not issue warnings or return here, further we'll try to search by id or idnumber. + $contextoptions = $this->get_context_options(); + if ($hash['contextid'] = array_search($hash['category'], $contextoptions)) { + return $warnings; + } + } + + // Now search by category id or category idnumber. + if (!empty($hash['category_id'])) { + $field = 'id'; + $value = clean_param($hash['category_id'], PARAM_INT); + } else if (!empty($hash['category_idnumber'])) { + $field = 'idnumber'; + $value = $hash['category_idnumber']; + } else if (!empty($hash['category'])) { + $field = is_numeric($hash['category']) ? 'id' : 'idnumber'; + $value = $hash['category']; + } else { + // No category field was specified, assume default category. + $hash['contextid'] = $defaultcontext->id; + return $warnings; + } + + if (empty($this->categoriescache[$field][$value])) { + $record = $DB->get_record_sql("SELECT c.id, ctx.id contextid + FROM {context} ctx JOIN {course_categories} c ON ctx.contextlevel = ? AND ctx.instanceid = c.id + WHERE c.$field = ?", array(CONTEXT_COURSECAT, $value)); + if ($record && ($contextoptions = $this->get_context_options()) && isset($contextoptions[$record->contextid])) { + $contextid = $record->contextid; + } else { + $warnings[] = new lang_string('categorynotfound', 'cohort', s($value)); + $contextid = $defaultcontext->id; + } + // Next time when we can look up and don't search by this value again. + $this->categoriescache[$field][$value] = $contextid; + } + $hash['contextid'] = $this->categoriescache[$field][$value]; + + return $warnings; + } +} diff --git a/lang/en/cohort.php b/lang/en/cohort.php index e76b528128505..31942ded57abb 100644 --- a/lang/en/cohort.php +++ b/lang/en/cohort.php @@ -32,6 +32,7 @@ $string['backtocohorts'] = 'Back to cohorts'; $string['bulkadd'] = 'Add to cohort'; $string['bulknocohort'] = 'No available cohorts found'; +$string['categorynotfound'] = 'Category {$a} not found or you don\'t have permission to create a cohort there. The default context will be used.'; $string['cohort'] = 'Cohort'; $string['cohorts'] = 'Cohorts'; $string['cohortsin'] = '{$a}: available cohorts'; @@ -39,11 +40,17 @@ $string['cohort:manage'] = 'Manage cohorts'; $string['cohort:view'] = 'Use cohorts and view members'; $string['component'] = 'Source'; +$string['contextnotfound'] = 'Context {$a} not found or you don\'t have permission to create a cohort there. The default context will be used.'; +$string['csvcontainserrors'] = 'Errors were found in CSV data. See details below.'; +$string['csvcontainswarnings'] = 'Warnings were found in CSV data. See details below.'; +$string['csvextracolumns'] = 'Column(s) {$a} will be ignored.'; $string['currentusers'] = 'Current users'; $string['currentusersmatching'] = 'Current users matching'; +$string['defaultcontext'] = 'Default context'; $string['delcohort'] = 'Delete cohort'; $string['delconfirm'] = 'Do you really want to delete cohort \'{$a}\'?'; $string['description'] = 'Description'; +$string['displayedrows'] = '{$a->displayed} rows displayed out of {$a->total}.'; $string['duplicateidnumber'] = 'Cohort with the same ID number already exists'; $string['editcohort'] = 'Edit cohort'; $string['eventcohortcreated'] = 'Cohort created'; @@ -55,13 +62,26 @@ $string['idnumber'] = 'Cohort ID'; $string['memberscount'] = 'Cohort size'; $string['name'] = 'Name'; +$string['namecolumnmissing'] = 'There is something wrong with the format of the CSV file. Please check that it includes column names.'; +$string['namefieldempty'] = 'Field name can not be empty'; $string['nocomponent'] = 'Created manually'; $string['potusers'] = 'Potential users'; $string['potusersmatching'] = 'Potential matching users'; +$string['preview'] = 'Preview'; $string['removeuserwarning'] = 'Removing users from a cohort may result in unenrolling of users from multiple courses which includes deleting of user settings, grades, group membership and other user information from affected courses.'; $string['selectfromcohort'] = 'Select members from cohort'; $string['systemcohorts'] = 'System cohorts'; $string['unknowncohort'] = 'Unknown cohort ({$a})!'; +$string['uploadcohorts'] = 'Upload cohorts'; +$string['uploadedcohorts'] = 'Uploaded {$a} cohorts'; $string['useradded'] = 'User added to cohort "{$a}"'; $string['search'] = 'Search'; $string['searchcohort'] = 'Search cohort'; +$string['uploadcohorts_help'] = 'Cohorts may be uploaded via text file. The format of the file should be as follows: + +* Each line of the file contains one record +* Each record is a series of data separated by commas (or other delimiters) +* The first record contains a list of fieldnames defining the format of the rest of the file +* Required fieldname is name +* Optional fieldnames are idnumber, description, descriptionformat, context, category, category_id, category_idnumber, category_path +'; \ No newline at end of file