Search moodle.org's
Developer Documentation

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.
  • Differences Between: [Versions 310 and 34] [Versions 310 and 35] [Versions 34 and 310] [Versions 35 and 310]

       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  }