Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.
   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * A form for cohort upload.
  19   *
  20   * @package    core_cohort
  21   * @copyright  2014 Marina Glancy
  22   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  defined('MOODLE_INTERNAL') || die();
  26  
  27  require_once($CFG->libdir.'/formslib.php');
  28  
  29  /**
  30   * Cohort upload form class
  31   *
  32   * @package    core_cohort
  33   * @copyright  2014 Marina Glancy
  34   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  35   */
  36  class cohort_upload_form extends moodleform {
  37      /** @var array new cohorts that need to be created */
  38      public $processeddata = null;
  39      /** @var array cached list of available contexts */
  40      protected $contextoptions = null;
  41      /** @var array temporary cache for retrieved categories */
  42      protected $categoriescache = array();
  43  
  44      /**
  45       * Form definition
  46       */
  47      public function definition() {
  48          $mform = $this->_form;
  49          $data  = (object)$this->_customdata;
  50  
  51          $mform->addElement('header', 'cohortfileuploadform', get_string('uploadafile'));
  52  
  53          $filepickeroptions = array();
  54          $filepickeroptions['filetypes'] = '*';
  55          $filepickeroptions['maxbytes'] = get_max_upload_file_size();
  56          $mform->addElement('filepicker', 'cohortfile', get_string('file'), null, $filepickeroptions);
  57  
  58          $choices = csv_import_reader::get_delimiter_list();
  59          $mform->addElement('select', 'delimiter', get_string('csvdelimiter', 'tool_uploadcourse'), $choices);
  60          if (array_key_exists('cfg', $choices)) {
  61              $mform->setDefault('delimiter', 'cfg');
  62          } else if (get_string('listsep', 'langconfig') == ';') {
  63              $mform->setDefault('delimiter', 'semicolon');
  64          } else {
  65              $mform->setDefault('delimiter', 'comma');
  66          }
  67          $mform->addHelpButton('delimiter', 'csvdelimiter', 'tool_uploadcourse');
  68  
  69          $choices = core_text::get_encodings();
  70          $mform->addElement('select', 'encoding', get_string('encoding', 'tool_uploadcourse'), $choices);
  71          $mform->setDefault('encoding', 'UTF-8');
  72          $mform->addHelpButton('encoding', 'encoding', 'tool_uploadcourse');
  73  
  74          $options = $this->get_context_options();
  75          $mform->addElement('select', 'contextid', get_string('defaultcontext', 'cohort'), $options);
  76  
  77          $this->add_cohort_upload_buttons(true);
  78          $this->set_data($data);
  79      }
  80  
  81      /**
  82       * Add buttons to the form ("Upload cohorts", "Preview", "Cancel")
  83       */
  84      protected function add_cohort_upload_buttons() {
  85          $mform = $this->_form;
  86  
  87          $buttonarray = array();
  88  
  89          $submitlabel = get_string('uploadcohorts', 'cohort');
  90          $buttonarray[] = $mform->createElement('submit', 'submitbutton', $submitlabel);
  91  
  92          $previewlabel = get_string('preview', 'cohort');
  93          $buttonarray[] = $mform->createElement('submit', 'previewbutton', $previewlabel);
  94          $mform->registerNoSubmitButton('previewbutton');
  95  
  96          $buttonarray[] = $mform->createElement('cancel');
  97  
  98          $mform->addGroup($buttonarray, 'buttonar', '', array(' '), false);
  99          $mform->closeHeaderBefore('buttonar');
 100      }
 101  
 102      /**
 103       * Process the uploaded file and allow the submit button only if it doest not have errors.
 104       */
 105      public function definition_after_data() {
 106          $mform = $this->_form;
 107          $cohortfile = $mform->getElementValue('cohortfile');
 108          $allowsubmitform = false;
 109          if ($cohortfile && ($file = $this->get_cohort_file($cohortfile))) {
 110              // File was uploaded. Parse it.
 111              $encoding = $mform->getElementValue('encoding')[0];
 112              $delimiter = $mform->getElementValue('delimiter')[0];
 113              $contextid = $mform->getElementValue('contextid')[0];
 114              if (!empty($contextid) && ($context = context::instance_by_id($contextid, IGNORE_MISSING))) {
 115                  $this->processeddata = $this->process_upload_file($file, $encoding, $delimiter, $context);
 116                  if ($this->processeddata && count($this->processeddata) > 1 && !$this->processeddata[0]['errors']) {
 117                      $allowsubmitform = true;
 118                  }
 119              }
 120          }
 121          if (!$allowsubmitform) {
 122              // Hide submit button.
 123              $el = $mform->getElement('buttonar')->getElements()[0];
 124              $el->setValue('');
 125              $el->freeze();
 126          } else {
 127              $mform->setExpanded('cohortfileuploadform', false);
 128          }
 129  
 130      }
 131  
 132      /**
 133       * Returns the list of contexts where current user can create cohorts.
 134       *
 135       * @return array
 136       */
 137      protected function get_context_options() {
 138          if ($this->contextoptions === null) {
 139              $this->contextoptions = array();
 140              $displaylist = core_course_category::make_categories_list('moodle/cohort:manage');
 141              // We need to index the options array by context id instead of category id and add option for system context.
 142              $syscontext = context_system::instance();
 143              if (has_capability('moodle/cohort:manage', $syscontext)) {
 144                  $this->contextoptions[$syscontext->id] = $syscontext->get_context_name();
 145              }
 146              foreach ($displaylist as $cid => $name) {
 147                  $context = context_coursecat::instance($cid);
 148                  $this->contextoptions[$context->id] = $name;
 149              }
 150          }
 151          return $this->contextoptions;
 152      }
 153  
 154      public function validation($data, $files) {
 155          $errors = parent::validation($data, $files);
 156          if (empty($errors)) {
 157              if (empty($data['cohortfile']) || !($file = $this->get_cohort_file($data['cohortfile']))) {
 158                  $errors['cohortfile'] = get_string('required');
 159              } else {
 160                  if (!empty($this->processeddata[0]['errors'])) {
 161                      // Any value in $errors will notify that validation did not pass. The detailed errors will be shown in preview.
 162                      $errors['dummy'] = '';
 163                  }
 164              }
 165          }
 166          return $errors;
 167      }
 168  
 169      /**
 170       * Returns the uploaded file if it is present.
 171       *
 172       * @param int $draftid
 173       * @return stored_file|null
 174       */
 175      protected function get_cohort_file($draftid) {
 176          global $USER;
 177          // We can not use moodleform::get_file_content() method because we need the content before the form is validated.
 178          if (!$draftid) {
 179              return null;
 180          }
 181          $fs = get_file_storage();
 182          $context = context_user::instance($USER->id);
 183          if (!$files = $fs->get_area_files($context->id, 'user', 'draft', $draftid, 'id DESC', false)) {
 184              return null;
 185          }
 186          $file = reset($files);
 187  
 188          return $file;
 189  
 190      }
 191  
 192      /**
 193       * Returns the list of prepared objects to be added as cohorts
 194       *
 195       * @return array of stdClass objects, each can be passed to {@link cohort_add_cohort()}
 196       */
 197      public function get_cohorts_data() {
 198          $cohorts = array();
 199          if ($this->processeddata) {
 200              foreach ($this->processeddata as $idx => $line) {
 201                  if ($idx && !empty($line['data'])) {
 202                      $cohorts[] = (object)$line['data'];
 203                  }
 204              }
 205          }
 206          return $cohorts;
 207      }
 208  
 209      /**
 210       * Displays the preview of the uploaded file
 211       */
 212      protected function preview_uploaded_cohorts() {
 213          global $OUTPUT;
 214          if (empty($this->processeddata)) {
 215              return;
 216          }
 217          foreach ($this->processeddata[0]['errors'] as $error) {
 218              echo $OUTPUT->notification($error);
 219          }
 220          foreach ($this->processeddata[0]['warnings'] as $warning) {
 221              echo $OUTPUT->notification($warning, 'notifymessage');
 222          }
 223          $table = new html_table();
 224          $table->id = 'previewuploadedcohorts';
 225          $columns = $this->processeddata[0]['data'];
 226          $columns['contextid'] = get_string('context', 'role');
 227  
 228          // Add column names to the preview table.
 229          $table->head = array('');
 230          foreach ($columns as $key => $value) {
 231              $table->head[] = $value;
 232          }
 233          $table->head[] = get_string('status');
 234  
 235          // Add (some) rows to the preview table.
 236          $previewdrows = $this->get_previewed_rows();
 237          foreach ($previewdrows as $idx) {
 238              $line = $this->processeddata[$idx];
 239              $cells = array(new html_table_cell($idx));
 240              $context = context::instance_by_id($line['data']['contextid']);
 241              foreach ($columns as $key => $value) {
 242                  if ($key === 'contextid') {
 243                      $text = html_writer::link(new moodle_url('/cohort/index.php', array('contextid' => $context->id)),
 244                          $context->get_context_name(false));
 245                  } else {
 246                      $text = s($line['data'][$key]);
 247                  }
 248                  $cells[] = new html_table_cell($text);
 249              }
 250              $text = '';
 251              if ($line['errors']) {
 252                  $text .= html_writer::div(join('<br>', $line['errors']), 'notifyproblem');
 253              }
 254              if ($line['warnings']) {
 255                  $text .= html_writer::div(join('<br>', $line['warnings']));
 256              }
 257              $cells[] = new html_table_cell($text);
 258              $table->data[] = new html_table_row($cells);
 259          }
 260          if ($notdisplayed = count($this->processeddata) - count($previewdrows) - 1) {
 261              $cell = new html_table_cell(get_string('displayedrows', 'cohort',
 262                  (object)array('displayed' => count($previewdrows), 'total' => count($this->processeddata) - 1)));
 263              $cell->colspan = count($columns) + 2;
 264              $table->data[] = new html_table_row(array($cell));
 265          }
 266          echo html_writer::table($table);
 267      }
 268  
 269      /**
 270       * Find up rows to show in preview
 271       *
 272       * Number of previewed rows is limited but rows with errors and warnings have priority.
 273       *
 274       * @return array
 275       */
 276      protected function get_previewed_rows() {
 277          $previewlimit = 10;
 278          if (count($this->processeddata) <= 1) {
 279              $rows = array();
 280          } else if (count($this->processeddata) < $previewlimit + 1) {
 281              // Return all rows.
 282              $rows = range(1, count($this->processeddata) - 1);
 283          } else {
 284              // First find rows with errors and warnings (no more than 10 of each).
 285              $errorrows = $warningrows = array();
 286              foreach ($this->processeddata as $rownum => $line) {
 287                  if ($rownum && $line['errors']) {
 288                      $errorrows[] = $rownum;
 289                      if (count($errorrows) >= $previewlimit) {
 290                          return $errorrows;
 291                      }
 292                  } else if ($rownum && $line['warnings']) {
 293                      if (count($warningrows) + count($errorrows) < $previewlimit) {
 294                          $warningrows[] = $rownum;
 295                      }
 296                  }
 297              }
 298              // Include as many error rows as possible and top them up with warning rows.
 299              $rows = array_merge($errorrows, array_slice($warningrows, 0, $previewlimit - count($errorrows)));
 300              // Keep adding good rows until we reach limit.
 301              for ($rownum = 1; count($rows) < $previewlimit; $rownum++) {
 302                  if (!in_array($rownum, $rows)) {
 303                      $rows[] = $rownum;
 304                  }
 305              }
 306              asort($rows);
 307          }
 308          return $rows;
 309      }
 310  
 311      public function display() {
 312          // Finalize the form definition if not yet done.
 313          if (!$this->_definition_finalized) {
 314              $this->_definition_finalized = true;
 315              $this->definition_after_data();
 316          }
 317  
 318          // Difference from the parent display() method is that we want to show preview above the form if applicable.
 319          $this->preview_uploaded_cohorts();
 320  
 321          $this->_form->display();
 322      }
 323  
 324      /**
 325       * @param stored_file $file
 326       * @param string $encoding
 327       * @param string $delimiter
 328       * @param context $defaultcontext
 329       * @return array
 330       */
 331      protected function process_upload_file($file, $encoding, $delimiter, $defaultcontext) {
 332          global $CFG, $DB;
 333          require_once($CFG->libdir . '/csvlib.class.php');
 334  
 335          $cohorts = array(
 336              0 => array('errors' => array(), 'warnings' => array(), 'data' => array())
 337          );
 338  
 339          // Read and parse the CSV file using csv library.
 340          $content = $file->get_content();
 341          if (!$content) {
 342              $cohorts[0]['errors'][] = new lang_string('csvemptyfile', 'error');
 343              return $cohorts;
 344          }
 345  
 346          $uploadid = csv_import_reader::get_new_iid('uploadcohort');
 347          $cir = new csv_import_reader($uploadid, 'uploadcohort');
 348          $readcount = $cir->load_csv_content($content, $encoding, $delimiter);
 349          unset($content);
 350          if (!$readcount) {
 351              $cohorts[0]['errors'][] = get_string('csvloaderror', 'error', $cir->get_error());
 352              return $cohorts;
 353          }
 354          $columns = $cir->get_columns();
 355  
 356          // Check that columns include 'name' and warn about extra columns.
 357          $allowedcolumns = array('contextid', 'name', 'idnumber', 'description', 'descriptionformat', 'visible', 'theme');
 358          $additionalcolumns = array('context', 'category', 'category_id', 'category_idnumber', 'category_path');
 359          $displaycolumns = array();
 360          $extracolumns = array();
 361          $columnsmapping = array();
 362          foreach ($columns as $i => $columnname) {
 363              $columnnamelower = preg_replace('/ /', '', core_text::strtolower($columnname));
 364              $columnsmapping[$i] = null;
 365              if (in_array($columnnamelower, $allowedcolumns)) {
 366                  $displaycolumns[$columnnamelower] = $columnname;
 367                  $columnsmapping[$i] = $columnnamelower;
 368              } else if (in_array($columnnamelower, $additionalcolumns)) {
 369                  $columnsmapping[$i] = $columnnamelower;
 370              } else {
 371                  $extracolumns[] = $columnname;
 372              }
 373          }
 374          if (!in_array('name', $columnsmapping)) {
 375              $cohorts[0]['errors'][] = new lang_string('namecolumnmissing', 'cohort');
 376              return $cohorts;
 377          }
 378          if ($extracolumns) {
 379              $cohorts[0]['warnings'][] = new lang_string('csvextracolumns', 'cohort', s(join(', ', $extracolumns)));
 380          }
 381  
 382          if (!isset($displaycolumns['contextid'])) {
 383              $displaycolumns['contextid'] = 'contextid';
 384          }
 385          $cohorts[0]['data'] = $displaycolumns;
 386  
 387          // Parse data rows.
 388          $cir->init();
 389          $rownum = 0;
 390          $idnumbers = array();
 391          $haserrors = false;
 392          $haswarnings = false;
 393          while ($row = $cir->next()) {
 394              $rownum++;
 395              $cohorts[$rownum] = array(
 396                  'errors' => array(),
 397                  'warnings' => array(),
 398                  'data' => array(),
 399              );
 400              $hash = array();
 401              foreach ($row as $i => $value) {
 402                  if ($columnsmapping[$i]) {
 403                      $hash[$columnsmapping[$i]] = $value;
 404                  }
 405              }
 406              $this->clean_cohort_data($hash);
 407  
 408              $warnings = $this->resolve_context($hash, $defaultcontext);
 409              $cohorts[$rownum]['warnings'] = array_merge($cohorts[$rownum]['warnings'], $warnings);
 410  
 411              if (!empty($hash['idnumber'])) {
 412                  if (isset($idnumbers[$hash['idnumber']]) || $DB->record_exists('cohort', array('idnumber' => $hash['idnumber']))) {
 413                      $cohorts[$rownum]['errors'][] = new lang_string('duplicateidnumber', 'cohort');
 414                  }
 415                  $idnumbers[$hash['idnumber']] = true;
 416              }
 417  
 418              if (empty($hash['name'])) {
 419                  $cohorts[$rownum]['errors'][] = new lang_string('namefieldempty', 'cohort');
 420              }
 421  
 422              if (!empty($hash['theme']) && !empty($CFG->allowcohortthemes)) {
 423                  $availablethemes = cohort_get_list_of_themes();
 424                  if (empty($availablethemes[$hash['theme']])) {
 425                      $cohorts[$rownum]['errors'][] = new lang_string('invalidtheme', 'cohort');
 426                  }
 427              }
 428  
 429              $cohorts[$rownum]['data'] = array_intersect_key($hash, $cohorts[0]['data']);
 430              $haserrors = $haserrors || !empty($cohorts[$rownum]['errors']);
 431              $haswarnings = $haswarnings || !empty($cohorts[$rownum]['warnings']);
 432          }
 433  
 434          if ($haserrors) {
 435              $cohorts[0]['errors'][] = new lang_string('csvcontainserrors', 'cohort');
 436          }
 437  
 438          if ($haswarnings) {
 439              $cohorts[0]['warnings'][] = new lang_string('csvcontainswarnings', 'cohort');
 440          }
 441  
 442          return $cohorts;
 443      }
 444  
 445      /**
 446       * Cleans input data about one cohort.
 447       *
 448       * @param array $hash
 449       */
 450      protected function clean_cohort_data(&$hash) {
 451          foreach ($hash as $key => $value) {
 452              switch ($key) {
 453                  case 'contextid': $hash[$key] = clean_param($value, PARAM_INT); break;
 454                  case 'name': $hash[$key] = core_text::substr(clean_param($value, PARAM_TEXT), 0, 254); break;
 455                  case 'idnumber': $hash[$key] = core_text::substr(clean_param($value, PARAM_RAW), 0, 254); break;
 456                  case 'description': $hash[$key] = clean_param($value, PARAM_RAW); break;
 457                  case 'descriptionformat': $hash[$key] = clean_param($value, PARAM_INT); break;
 458                  case 'visible':
 459                      $tempstr = trim(core_text::strtolower($value));
 460                      if ($tempstr === '') {
 461                          // Empty string is treated as "YES" (the default value for cohort visibility).
 462                          $hash[$key] = 1;
 463                      } else {
 464                          if ($tempstr === core_text::strtolower(get_string('no')) || $tempstr === 'n') {
 465                              // Special treatment for 'no' string that is not included in clean_param().
 466                              $value = 0;
 467                          }
 468                          $hash[$key] = clean_param($value, PARAM_BOOL) ? 1 : 0;
 469                      }
 470                      break;
 471                  case 'theme':
 472                      $hash[$key] = core_text::substr(clean_param($value, PARAM_TEXT), 0, 50);
 473                      break;
 474              }
 475          }
 476      }
 477  
 478      /**
 479       * Determines in which context the particular cohort will be created
 480       *
 481       * @param array $hash
 482       * @param context $defaultcontext
 483       * @return array array of warning strings
 484       */
 485      protected function resolve_context(&$hash, $defaultcontext) {
 486          global $DB;
 487  
 488          $warnings = array();
 489  
 490          if (!empty($hash['contextid'])) {
 491              // Contextid was specified, verify we can post there.
 492              $contextoptions = $this->get_context_options();
 493              if (!isset($contextoptions[$hash['contextid']])) {
 494                  $warnings[] = new lang_string('contextnotfound', 'cohort', $hash['contextid']);
 495                  $hash['contextid'] = $defaultcontext->id;
 496              }
 497              return $warnings;
 498          }
 499  
 500          if (!empty($hash['context'])) {
 501              $systemcontext = context_system::instance();
 502              if ((core_text::strtolower(trim($hash['context'])) ===
 503                      core_text::strtolower($systemcontext->get_context_name())) ||
 504                      ('' . $hash['context'] === '' . $systemcontext->id)) {
 505                  // User meant system context.
 506                  $hash['contextid'] = $systemcontext->id;
 507                  $contextoptions = $this->get_context_options();
 508                  if (!isset($contextoptions[$hash['contextid']])) {
 509                      $warnings[] = new lang_string('contextnotfound', 'cohort', $hash['context']);
 510                      $hash['contextid'] = $defaultcontext->id;
 511                  }
 512              } else {
 513                  // Assume it is a category.
 514                  $hash['category'] = trim($hash['context']);
 515              }
 516          }
 517  
 518          if (!empty($hash['category_path'])) {
 519              // We already have array with available categories, look up the value.
 520              $contextoptions = $this->get_context_options();
 521              if (!$hash['contextid'] = array_search($hash['category_path'], $contextoptions)) {
 522                  $warnings[] = new lang_string('categorynotfound', 'cohort', s($hash['category_path']));
 523                  $hash['contextid'] = $defaultcontext->id;
 524              }
 525              return $warnings;
 526          }
 527  
 528          if (!empty($hash['category'])) {
 529              // Quick search by category path first.
 530              // Do not issue warnings or return here, further we'll try to search by id or idnumber.
 531              $contextoptions = $this->get_context_options();
 532              if ($hash['contextid'] = array_search($hash['category'], $contextoptions)) {
 533                  return $warnings;
 534              }
 535          }
 536  
 537          // Now search by category id or category idnumber.
 538          if (!empty($hash['category_id'])) {
 539              $field = 'id';
 540              $value = clean_param($hash['category_id'], PARAM_INT);
 541          } else if (!empty($hash['category_idnumber'])) {
 542              $field = 'idnumber';
 543              $value = $hash['category_idnumber'];
 544          } else if (!empty($hash['category'])) {
 545              $field = is_numeric($hash['category']) ? 'id' : 'idnumber';
 546              $value = $hash['category'];
 547          } else {
 548              // No category field was specified, assume default category.
 549              $hash['contextid'] = $defaultcontext->id;
 550              return $warnings;
 551          }
 552  
 553          if (empty($this->categoriescache[$field][$value])) {
 554              $record = $DB->get_record_sql("SELECT c.id, ctx.id contextid
 555                  FROM {context} ctx JOIN {course_categories} c ON ctx.contextlevel = ? AND ctx.instanceid = c.id
 556                  WHERE c.$field = ?", array(CONTEXT_COURSECAT, $value));
 557              if ($record && ($contextoptions = $this->get_context_options()) && isset($contextoptions[$record->contextid])) {
 558                  $contextid = $record->contextid;
 559              } else {
 560                  $warnings[] = new lang_string('categorynotfound', 'cohort', s($value));
 561                  $contextid = $defaultcontext->id;
 562              }
 563              // Next time when we can look up and don't search by this value again.
 564              $this->categoriescache[$field][$value] = $contextid;
 565          }
 566          $hash['contextid'] = $this->categoriescache[$field][$value];
 567  
 568          return $warnings;
 569      }
 570  }