Merge branch 'MDL-46599-master' of git://github.com/lameze/moodle
[moodle.git] / grade / import / csv / classes / load_data.php
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/>.
17 /**
18  * A class for loading and preparing grade data from import.
19  *
20  * @package   gradeimport_csv
21  * @copyright 2014 Adrian Greeve <adrian@moodle.com>
22  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 defined('MOODLE_INTERNAL') || die();
27 /**
28  * A class for loading and preparing grade data from import.
29  *
30  * @package   gradeimport_csv
31  * @copyright 2014 Adrian Greeve <adrian@moodle.com>
32  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
33  */
34 class gradeimport_csv_load_data {
36     /** @var string $error csv import error. */
37     protected $error;
38     /** @var int $iid Unique identifier for these csv records. */
39     protected $iid;
40     /** @var array $headers Column names for the data. */
41     protected $headers;
42     /** @var array $previewdata A subsection of the csv imported data. */
43     protected $previewdata;
45     // The map_user_data_with_value variables.
46     /** @var array $newgrades Grades to be inserted into the gradebook. */
47     protected $newgrades;
48     /** @var array $newfeedbacks Feedback to be inserted into the gradebook. */
49     protected $newfeedbacks;
50     /** @var int $studentid Student ID*/
51     protected $studentid;
53     // The prepare_import_grade_data() variables.
54     /** @var bool $status The current status of the import. True = okay, False = errors. */
55     protected $status;
56     /** @var int $importcode The code for this batch insert. */
57     protected $importcode;
58     /** @var array $gradebookerrors An array of errors from trying to import into the gradebook. */
59     protected $gradebookerrors;
60     /** @var array $newgradeitems An array of new grade items to be inserted into the gradebook. */
61     protected $newgradeitems;
63     /**
64      * Load CSV content for previewing.
65      *
66      * @param string $text The grade data being imported.
67      * @param string $encoding The type of encoding the file uses.
68      * @param string $separator The separator being used to define each field.
69      * @param int $previewrows How many rows are being previewed.
70      */
71     public function load_csv_content($text, $encoding, $separator, $previewrows) {
72         $this->raise_limits();
74         $this->iid = csv_import_reader::get_new_iid('grade');
75         $csvimport = new csv_import_reader($this->iid, 'grade');
77         $csvimport->load_csv_content($text, $encoding, $separator);
78         $this->error = $csvimport->get_error();
80         // If there are no import errors then proceed.
81         if (empty($this->error)) {
83             // Get header (field names).
84             $this->headers = $csvimport->get_columns();
85             $this->trim_headers();
87             $csvimport->init();
88             $this->previewdata = array();
90             for ($numlines = 0; $numlines <= $previewrows; $numlines++) {
91                 $lines = $csvimport->next();
92                 if ($lines) {
93                     $this->previewdata[] = $lines;
94                 }
95             }
96         }
97     }
99     /**
100      * Gets all of the grade items in this course.
101      *
102      * @param int $courseid Course id;
103      * @return array An array of grade items for the course.
104      */
105     public static function fetch_grade_items($courseid) {
106         $gradeitems = null;
107         if ($allgradeitems = grade_item::fetch_all(array('courseid' => $courseid))) {
108             foreach ($allgradeitems as $gradeitem) {
109                 // Skip course type and category type.
110                 if ($gradeitem->itemtype == 'course' || $gradeitem->itemtype == 'category') {
111                     continue;
112                 }
114                 $displaystring = null;
115                 if (!empty($gradeitem->itemmodule)) {
116                     $displaystring = get_string('modulename', $gradeitem->itemmodule).get_string('labelsep', 'langconfig')
117                             .$gradeitem->get_name();
118                 } else {
119                     $displaystring = $gradeitem->get_name();
120                 }
121                 $gradeitems[$gradeitem->id] = $displaystring;
122             }
123         }
124         return $gradeitems;
125     }
127     /**
128      * Cleans the column headers from the CSV file.
129      */
130     protected function trim_headers() {
131         foreach ($this->headers as $i => $h) {
132             $h = trim($h); // Remove whitespace.
133             $h = clean_param($h, PARAM_RAW); // Clean the header.
134             $this->headers[$i] = $h;
135         }
136     }
138     /**
139      * Raises the php execution time and memory limits for importing the CSV file.
140      */
141     protected function raise_limits() {
142         // Large files are likely to take their time and memory. Let PHP know
143         // that we'll take longer, and that the process should be recycled soon
144         // to free up memory.
145         core_php_time_limit::raise();
146         raise_memory_limit(MEMORY_EXTRA);
147     }
149     /**
150      * Inserts a record into the grade_import_values table. This also adds common record information.
151      *
152      * @param object $record The grade record being inserted into the database.
153      * @param int $studentid The student ID.
154      * @return bool|int true or insert id on success. Null if the grade value is too high.
155      */
156     protected function insert_grade_record($record, $studentid) {
157         global $DB, $USER, $CFG;
158         $record->importcode = $this->importcode;
159         $record->userid     = $studentid;
160         $record->importer   = $USER->id;
161         // By default the maximum grade is 100.
162         $gradepointmaximum = 100;
163         // If the grade limit has been increased then use the gradepointmax setting.
164         if ($CFG->unlimitedgrades) {
165             $gradepointmaximum = $CFG->gradepointmax;
166         }
167         // If the record final grade is set then check that the grade value isn't too high.
168         // Final grade will not be set if we are inserting feedback.
169         if (!isset($record->finalgrade) || $record->finalgrade <= $gradepointmaximum) {
170             return $DB->insert_record('grade_import_values', $record);
171         } else {
172             $this->cleanup_import(get_string('gradevaluetoobig', 'grades', $gradepointmaximum));
173             return null;
174         }
175     }
177     /**
178      * Insert the new grade into the grade item buffer table.
179      *
180      * @param array $header The column headers from the CSV file.
181      * @param int $key Current row identifier.
182      * @param string $value The value for this row (final grade).
183      * @return array new grades that are ready for commiting to the gradebook.
184      */
185     protected function import_new_grade_item($header, $key, $value) {
186         global $DB, $USER;
188         // First check if header is already in temp database.
189         if (empty($this->newgradeitems[$key])) {
191             $newgradeitem = new stdClass();
192             $newgradeitem->itemname = $header[$key];
193             $newgradeitem->importcode = $this->importcode;
194             $newgradeitem->importer = $USER->id;
196             // Insert into new grade item buffer.
197             $this->newgradeitems[$key] = $DB->insert_record('grade_import_newitem', $newgradeitem);
198         }
199         $newgrade = new stdClass();
200         $newgrade->newgradeitem = $this->newgradeitems[$key];
202         // If the user has a grade for this grade item.
203         if (trim($value) != '-') {
204             // Instead of omitting the grade we could insert one with finalgrade set to 0.
205             // We do not have access to grade item min grade.
206             $newgrade->finalgrade = $value;
207             $newgrades[] = $newgrade;
208         }
209         return $newgrades;
210     }
212     /**
213      * Check that the user is in the system.
214      *
215      * @param string $value The value, from the csv file, being mapped to identify the user.
216      * @param array $userfields Contains the field and label being mapped from.
217      * @return int Returns the user ID if it exists, otherwise null.
218      */
219     protected function check_user_exists($value, $userfields) {
220         global $DB;
222         $usercheckproblem = false;
223         $user = null;
224         // The user may use the incorrect field to match the user. This could result in an exception.
225         try {
226             $user = $DB->get_record('user', array($userfields['field'] => $value));
227         } catch (Exception $e) {
228             $usercheckproblem = true;
229         }
230         // Field may be fine, but no records were returned.
231         if (!$user || $usercheckproblem) {
232             $usermappingerrorobj = new stdClass();
233             $usermappingerrorobj->field = $userfields['label'];
234             $usermappingerrorobj->value = $value;
235             $this->cleanup_import(get_string('usermappingerror', 'grades', $usermappingerrorobj));
236             unset($usermappingerrorobj);
237             return null;
238         }
239         return $user->id;
240     }
242     /**
243      * Check to see if the feedback matches a grade item.
244      *
245      * @param int $courseid The course ID.
246      * @param int $itemid The ID of the grade item that the feedback relates to.
247      * @param string $value The actual feedback being imported.
248      * @return object Creates a feedback object with the item ID and the feedback value.
249      */
250     protected function create_feedback($courseid, $itemid, $value) {
251         // Case of an id, only maps id of a grade_item.
252         // This was idnumber.
253         if (!new grade_item(array('id' => $itemid, 'courseid' => $courseid))) {
254             // Supplied bad mapping, should not be possible since user
255             // had to pick mapping.
256             $this->cleanup_import(get_string('importfailed', 'grades'));
257             return null;
258         }
260         // The itemid is the id of the grade item.
261         $feedback = new stdClass();
262         $feedback->itemid   = $itemid;
263         $feedback->feedback = $value;
264         return $feedback;
265     }
267     /**
268      * This updates existing grade items.
269      *
270      * @param int $courseid The course ID.
271      * @param array $map Mapping information provided by the user.
272      * @param int $key The line that we are currently working on.
273      * @param bool $verbosescales Form setting for grading with scales.
274      * @param string $value The grade value.
275      * @return array grades to be updated.
276      */
277     protected function update_grade_item($courseid, $map, $key, $verbosescales, $value) {
278         // Case of an id, only maps id of a grade_item.
279         // This was idnumber.
280         if (!$gradeitem = new grade_item(array('id' => $map[$key], 'courseid' => $courseid))) {
281             // Supplied bad mapping, should not be possible since user
282             // had to pick mapping.
283             $this->cleanup_import(get_string('importfailed', 'grades'));
284             return null;
285         }
287         // Check if grade item is locked if so, abort.
288         if ($gradeitem->is_locked()) {
289             $this->cleanup_import(get_string('gradeitemlocked', 'grades'));
290             return null;
291         }
293         $newgrade = new stdClass();
294         $newgrade->itemid = $gradeitem->id;
295         if ($gradeitem->gradetype == GRADE_TYPE_SCALE and $verbosescales) {
296             if ($value === '' or $value == '-') {
297                 $value = null; // No grade.
298             } else {
299                 $scale = $gradeitem->load_scale();
300                 $scales = explode(',', $scale->scale);
301                 $scales = array_map('trim', $scales); // Hack - trim whitespace around scale options.
302                 array_unshift($scales, '-'); // Scales start at key 1.
303                 $key = array_search($value, $scales);
304                 if ($key === false) {
305                     $this->cleanup_import(get_string('badgrade', 'grades'));
306                     return null;
307                 }
308                 $value = $key;
309             }
310             $newgrade->finalgrade = $value;
311         } else {
312             if ($value === '' or $value == '-') {
313                 $value = null; // No grade.
314             } else {
315                 // If the value has a local decimal or can correctly be unformatted, do it.
316                 $validvalue = unformat_float($value, true);
317                 if ($validvalue !== false) {
318                     $value = $validvalue;
319                 } else {
320                     // Non numeric grade value supplied, possibly mapped wrong column.
321                     $this->cleanup_import(get_string('badgrade', 'grades'));
322                     return null;
323                 }
324             }
325             $newgrade->finalgrade = $value;
326         }
327         $this->newgrades[] = $newgrade;
328         return $this->newgrades;
329     }
331     /**
332      * Clean up failed CSV grade import. Clears the temp table for inserting grades.
333      *
334      * @param string $notification The error message to display from the unsuccessful grade import.
335      */
336     protected function cleanup_import($notification) {
337         $this->status = false;
338         import_cleanup($this->importcode);
339         $this->gradebookerrors[] = $notification;
340     }
342     /**
343      * Check user mapping.
344      *
345      * @param string $mappingidentifier The user field that we are matching together.
346      * @param string $value The value we are checking / importing.
347      * @param array $header The column headers of the csv file.
348      * @param array $map Mapping information provided by the user.
349      * @param int $key Current row identifier.
350      * @param int $courseid The course ID.
351      * @param int $feedbackgradeid The ID of the grade item that the feedback relates to.
352      * @param bool $verbosescales Form setting for grading with scales.
353      */
354     protected function map_user_data_with_value($mappingidentifier, $value, $header, $map, $key, $courseid, $feedbackgradeid,
355             $verbosescales) {
357         // Fields that the user can be mapped from.
358         $userfields = array(
359             'userid' => array(
360                 'field' => 'id',
361                 'label' => 'id',
362             ),
363             'useridnumber' => array(
364                 'field' => 'idnumber',
365                 'label' => 'idnumber',
366             ),
367             'useremail' => array(
368                 'field' => 'email',
369                 'label' => 'email address',
370             ),
371             'username' => array(
372                 'field' => 'username',
373                 'label' => 'username',
374             ),
375         );
377         switch ($mappingidentifier) {
378             case 'userid':
379             case 'useridnumber':
380             case 'useremail':
381             case 'username':
382                 // Skip invalid row with blank user field.
383                 if (!empty($value)) {
384                     $this->studentid = $this->check_user_exists($value, $userfields[$mappingidentifier]);
385                 }
386             break;
387             case 'new':
388                 $this->newgrades = $this->import_new_grade_item($header, $key, $value);
389             break;
390             case 'feedback':
391                 if ($feedbackgradeid) {
392                     $feedback = $this->create_feedback($courseid, $feedbackgradeid, $value);
393                     if (isset($feedback)) {
394                         $this->newfeedbacks[] = $feedback;
395                     }
396                 }
397             break;
398             default:
399                 // Existing grade items.
400                 if (!empty($map[$key])) {
401                     $this->newgrades = $this->update_grade_item($courseid, $map, $key, $verbosescales, $value,
402                             $mappingidentifier);
403                 }
404                 // Otherwise, we ignore this column altogether because user has chosen
405                 // to ignore them (e.g. institution, address etc).
406             break;
407         }
408     }
410     /**
411      * Checks and prepares grade data for inserting into the gradebook.
412      *
413      * @param array $header Column headers of the CSV file.
414      * @param object $formdata Mapping information from the preview page.
415      * @param object $csvimport csv import reader object for iterating over the imported CSV file.
416      * @param int $courseid The course ID.
417      * @param bool $separatemode If we have groups are they separate?
418      * @param mixed $currentgroup current group information.
419      * @param bool $verbosescales Form setting for grading with scales.
420      * @return bool True if the status for importing is okay, false if there are errors.
421      */
422     public function prepare_import_grade_data($header, $formdata, $csvimport, $courseid, $separatemode, $currentgroup,
423             $verbosescales) {
424         global $DB, $USER;
426         // The import code is used for inserting data into the grade tables.
427         $this->importcode = $formdata->importcode;
428         $this->status = true;
429         $this->headers = $header;
430         $this->studentid = null;
431         $this->gradebookerrors = null;
432         $forceimport = $formdata->forceimport;
433         // Temporary array to keep track of what new headers are processed.
434         $this->newgradeitems = array();
435         $this->trim_headers();
436         $timeexportkey = null;
437         $map = array();
438         // Loops mapping_0, mapping_1 .. mapping_n and construct $map array.
439         foreach ($header as $i => $head) {
440             if (isset($formdata->{'mapping_'.$i})) {
441                 $map[$i] = $formdata->{'mapping_'.$i};
442             }
443             if ($head == get_string('timeexported', 'gradeexport_txt')) {
444                 $timeexportkey = $i;
445             }
446         }
448         // If mapping information is supplied.
449         $map[clean_param($formdata->mapfrom, PARAM_RAW)] = clean_param($formdata->mapto, PARAM_RAW);
451         // Check for mapto collisions.
452         $maperrors = array();
453         foreach ($map as $i => $j) {
454             if ($j == 0) {
455                 // You can have multiple ignores.
456                 continue;
457             } else {
458                 if (!isset($maperrors[$j])) {
459                     $maperrors[$j] = true;
460                 } else {
461                     // Collision.
462                     print_error('cannotmapfield', '', '', $j);
463                 }
464             }
465         }
467         $this->raise_limits();
469         $csvimport->init();
471         while ($line = $csvimport->next()) {
472             if (count($line) <= 1) {
473                 // There is no data on this line, move on.
474                 continue;
475             }
477             // Array to hold all grades to be inserted.
478             $this->newgrades = array();
479             // Array to hold all feedback.
480             $this->newfeedbacks = array();
481             // Each line is a student record.
482             foreach ($line as $key => $value) {
484                 $value = clean_param($value, PARAM_RAW);
485                 $value = trim($value);
487                 /*
488                  * the options are
489                  * 1) userid, useridnumber, usermail, username - used to identify user row
490                  * 2) new - new grade item
491                  * 3) id - id of the old grade item to map onto
492                  * 3) feedback_id - feedback for grade item id
493                  */
495                 // Explode the mapping for feedback into a label 'feedback' and the identifying number.
496                 $mappingbase = explode("_", $map[$key]);
497                 $mappingidentifier = $mappingbase[0];
498                 // Set the feedback identifier if it exists.
499                 if (isset($mappingbase[1])) {
500                     $feedbackgradeid = (int)$mappingbase[1];
501                 } else {
502                     $feedbackgradeid = '';
503                 }
505                 $this->map_user_data_with_value($mappingidentifier, $value, $header, $map, $key, $courseid, $feedbackgradeid,
506                         $verbosescales);
507                 if ($this->status === false) {
508                     return $this->status;
509                 }
510             }
512             // No user mapping supplied at all, or user mapping failed.
513             if (empty($this->studentid) || !is_numeric($this->studentid)) {
514                 // User not found, abort whole import.
515                 $this->cleanup_import(get_string('usermappingerrorusernotfound', 'grades'));
516                 break;
517             }
519             if ($separatemode and !groups_is_member($currentgroup, $this->studentid)) {
520                 // Not allowed to import into this group, abort.
521                 $this->cleanup_import(get_string('usermappingerrorcurrentgroup', 'grades'));
522                 break;
523             }
525             // Insert results of this students into buffer.
526             if ($this->status and !empty($this->newgrades)) {
528                 foreach ($this->newgrades as $newgrade) {
530                     // Check if grade_grade is locked and if so, abort.
531                     if (!empty($newgrade->itemid) and $gradegrade = new grade_grade(array('itemid' => $newgrade->itemid,
532                             'userid' => $this->studentid))) {
533                         if ($gradegrade->is_locked()) {
534                             // Individual grade locked.
535                             $this->cleanup_import(get_string('gradelocked', 'grades'));
536                             return $this->status;
537                         }
538                         // Check if the force import option is disabled and the last exported date column is present.
539                         if (!$forceimport && !empty($timeexportkey)) {
540                             $exportedtime = $line[$timeexportkey];
541                             if (clean_param($exportedtime, PARAM_INT) != $exportedtime || $exportedtime > time() ||
542                                     $exportedtime < strtotime("-1 year", time())) {
543                                 // The date is invalid, or in the future, or more than a year old.
544                                 $this->cleanup_import(get_string('invalidgradeexporteddate', 'grades'));
545                                 return $this->status;
547                             }
548                             $timemodified = $gradegrade->get_dategraded();
549                             if (!empty($timemodified) && ($exportedtime < $timemodified)) {
550                                 // The item was graded after we exported it, we return here not to override it.
551                                 $user = core_user::get_user($this->studentid);
552                                 $this->cleanup_import(get_string('gradealreadyupdated', 'grades', fullname($user)));
553                                 return $this->status;
554                             }
555                         }
556                     }
557                     $insertid = self::insert_grade_record($newgrade, $this->studentid);
558                     // Check to see if the insert was successful.
559                     if (empty($insertid)) {
560                         return null;
561                     }
562                 }
563             }
565             // Updating/inserting all comments here.
566             if ($this->status and !empty($this->newfeedbacks)) {
567                 foreach ($this->newfeedbacks as $newfeedback) {
568                     $sql = "SELECT *
569                               FROM {grade_import_values}
570                              WHERE importcode=? AND userid=? AND itemid=? AND importer=?";
571                     if ($feedback = $DB->get_record_sql($sql, array($this->importcode, $this->studentid, $newfeedback->itemid,
572                             $USER->id))) {
573                         $newfeedback->id = $feedback->id;
574                         $DB->update_record('grade_import_values', $newfeedback);
576                     } else {
577                         // The grade item for this is not updated.
578                         $insertid = self::insert_grade_record($newfeedback, $this->studentid);
579                         // Check to see if the insert was successful.
580                         if (empty($insertid)) {
581                             return null;
582                         }
583                     }
584                 }
585             }
586         }
587         return $this->status;
588     }
590     /**
591      * Returns the headers parameter for this class.
592      *
593      * @return array returns headers parameter for this class.
594      */
595     public function get_headers() {
596         return $this->headers;
597     }
599     /**
600      * Returns the error parameter for this class.
601      *
602      * @return string returns error parameter for this class.
603      */
604     public function get_error() {
605         return $this->error;
606     }
608     /**
609      * Returns the iid parameter for this class.
610      *
611      * @return int returns iid parameter for this class.
612      */
613     public function get_iid() {
614         return $this->iid;
615     }
617     /**
618      * Returns the preview_data parameter for this class.
619      *
620      * @return array returns previewdata parameter for this class.
621      */
622     public function get_previewdata() {
623         return $this->previewdata;
624     }
626     /**
627      * Returns the gradebookerrors parameter for this class.
628      *
629      * @return array returns gradebookerrors parameter for this class.
630      */
631     public function get_gradebookerrors() {
632         return $this->gradebookerrors;
633     }