Merge branch 'MDL-64958-master' of git://github.com/junpataleta/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 stdClass new grade that is 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         $trimmed = trim($value);
203         if ($trimmed === '' or $trimmed == '-') {
204             // Blank or dash grade means null, ie "no grade".
205             $newgrade->finalgrade = null;
206         } else {
207             // We have an actual grade.
208             $newgrade->finalgrade = $value;
209         }
210         $this->newgrades[] = $newgrade;
211         return $newgrade;
212     }
214     /**
215      * Check that the user is in the system.
216      *
217      * @param string $value The value, from the csv file, being mapped to identify the user.
218      * @param array $userfields Contains the field and label being mapped from.
219      * @return int Returns the user ID if it exists, otherwise null.
220      */
221     protected function check_user_exists($value, $userfields) {
222         global $DB;
224         $user = null;
225         $errorkey = false;
226         // The user may use the incorrect field to match the user. This could result in an exception.
227         try {
228             // Make sure the record exists and that there's only one matching record found.
229             $user = $DB->get_record('user', array($userfields['field'] => $value), '*', MUST_EXIST);
230         } catch (dml_missing_record_exception $missingex) {
231             $errorkey = 'usermappingerror';
232         } catch (dml_multiple_records_exception $multiex) {
233             $errorkey = 'usermappingerrormultipleusersfound';
234         }
235         // Field may be fine, but no records were returned.
236         if ($errorkey) {
237             $usermappingerrorobj = new stdClass();
238             $usermappingerrorobj->field = $userfields['label'];
239             $usermappingerrorobj->value = $value;
240             $this->cleanup_import(get_string($errorkey, 'grades', $usermappingerrorobj));
241             unset($usermappingerrorobj);
242             return null;
243         }
244         return $user->id;
245     }
247     /**
248      * Check to see if the feedback matches a grade item.
249      *
250      * @param int $courseid The course ID.
251      * @param int $itemid The ID of the grade item that the feedback relates to.
252      * @param string $value The actual feedback being imported.
253      * @return object Creates a feedback object with the item ID and the feedback value.
254      */
255     protected function create_feedback($courseid, $itemid, $value) {
256         // Case of an id, only maps id of a grade_item.
257         // This was idnumber.
258         if (!new grade_item(array('id' => $itemid, 'courseid' => $courseid))) {
259             // Supplied bad mapping, should not be possible since user
260             // had to pick mapping.
261             $this->cleanup_import(get_string('importfailed', 'grades'));
262             return null;
263         }
265         // The itemid is the id of the grade item.
266         $feedback = new stdClass();
267         $feedback->itemid   = $itemid;
268         $feedback->feedback = $value;
269         return $feedback;
270     }
272     /**
273      * This updates existing grade items.
274      *
275      * @param int $courseid The course ID.
276      * @param array $map Mapping information provided by the user.
277      * @param int $key The line that we are currently working on.
278      * @param bool $verbosescales Form setting for grading with scales.
279      * @param string $value The grade value.
280      * @return array grades to be updated.
281      */
282     protected function update_grade_item($courseid, $map, $key, $verbosescales, $value) {
283         // Case of an id, only maps id of a grade_item.
284         // This was idnumber.
285         if (!$gradeitem = new grade_item(array('id' => $map[$key], 'courseid' => $courseid))) {
286             // Supplied bad mapping, should not be possible since user
287             // had to pick mapping.
288             $this->cleanup_import(get_string('importfailed', 'grades'));
289             return null;
290         }
292         // Check if grade item is locked if so, abort.
293         if ($gradeitem->is_locked()) {
294             $this->cleanup_import(get_string('gradeitemlocked', 'grades'));
295             return null;
296         }
298         $newgrade = new stdClass();
299         $newgrade->itemid = $gradeitem->id;
300         if ($gradeitem->gradetype == GRADE_TYPE_SCALE and $verbosescales) {
301             if ($value === '' or $value == '-') {
302                 $value = null; // No grade.
303             } else {
304                 $scale = $gradeitem->load_scale();
305                 $scales = explode(',', $scale->scale);
306                 $scales = array_map('trim', $scales); // Hack - trim whitespace around scale options.
307                 array_unshift($scales, '-'); // Scales start at key 1.
308                 $key = array_search($value, $scales);
309                 if ($key === false) {
310                     $this->cleanup_import(get_string('badgrade', 'grades'));
311                     return null;
312                 }
313                 $value = $key;
314             }
315             $newgrade->finalgrade = $value;
316         } else {
317             if ($value === '' or $value == '-') {
318                 $value = null; // No grade.
319             } else {
320                 // If the value has a local decimal or can correctly be unformatted, do it.
321                 $validvalue = unformat_float($value, true);
322                 if ($validvalue !== false) {
323                     $value = $validvalue;
324                 } else {
325                     // Non numeric grade value supplied, possibly mapped wrong column.
326                     $this->cleanup_import(get_string('badgrade', 'grades'));
327                     return null;
328                 }
329             }
330             $newgrade->finalgrade = $value;
331         }
332         $this->newgrades[] = $newgrade;
333         return $this->newgrades;
334     }
336     /**
337      * Clean up failed CSV grade import. Clears the temp table for inserting grades.
338      *
339      * @param string $notification The error message to display from the unsuccessful grade import.
340      */
341     protected function cleanup_import($notification) {
342         $this->status = false;
343         import_cleanup($this->importcode);
344         $this->gradebookerrors[] = $notification;
345     }
347     /**
348      * Check user mapping.
349      *
350      * @param string $mappingidentifier The user field that we are matching together.
351      * @param string $value The value we are checking / importing.
352      * @param array $header The column headers of the csv file.
353      * @param array $map Mapping information provided by the user.
354      * @param int $key Current row identifier.
355      * @param int $courseid The course ID.
356      * @param int $feedbackgradeid The ID of the grade item that the feedback relates to.
357      * @param bool $verbosescales Form setting for grading with scales.
358      */
359     protected function map_user_data_with_value($mappingidentifier, $value, $header, $map, $key, $courseid, $feedbackgradeid,
360             $verbosescales) {
362         // Fields that the user can be mapped from.
363         $userfields = array(
364             'userid' => array(
365                 'field' => 'id',
366                 'label' => 'id',
367             ),
368             'useridnumber' => array(
369                 'field' => 'idnumber',
370                 'label' => 'idnumber',
371             ),
372             'useremail' => array(
373                 'field' => 'email',
374                 'label' => 'email address',
375             ),
376             'username' => array(
377                 'field' => 'username',
378                 'label' => 'username',
379             ),
380         );
382         switch ($mappingidentifier) {
383             case 'userid':
384             case 'useridnumber':
385             case 'useremail':
386             case 'username':
387                 // Skip invalid row with blank user field.
388                 if (!empty($value)) {
389                     $this->studentid = $this->check_user_exists($value, $userfields[$mappingidentifier]);
390                 }
391             break;
392             case 'new':
393                 $this->import_new_grade_item($header, $key, $value);
394             break;
395             case 'feedback':
396                 if ($feedbackgradeid) {
397                     $feedback = $this->create_feedback($courseid, $feedbackgradeid, $value);
398                     if (isset($feedback)) {
399                         $this->newfeedbacks[] = $feedback;
400                     }
401                 }
402             break;
403             default:
404                 // Existing grade items.
405                 if (!empty($map[$key])) {
406                     $this->newgrades = $this->update_grade_item($courseid, $map, $key, $verbosescales, $value,
407                             $mappingidentifier);
408                 }
409                 // Otherwise, we ignore this column altogether because user has chosen
410                 // to ignore them (e.g. institution, address etc).
411             break;
412         }
413     }
415     /**
416      * Checks and prepares grade data for inserting into the gradebook.
417      *
418      * @param array $header Column headers of the CSV file.
419      * @param object $formdata Mapping information from the preview page.
420      * @param object $csvimport csv import reader object for iterating over the imported CSV file.
421      * @param int $courseid The course ID.
422      * @param bool $separatemode If we have groups are they separate?
423      * @param mixed $currentgroup current group information.
424      * @param bool $verbosescales Form setting for grading with scales.
425      * @return bool True if the status for importing is okay, false if there are errors.
426      */
427     public function prepare_import_grade_data($header, $formdata, $csvimport, $courseid, $separatemode, $currentgroup,
428             $verbosescales) {
429         global $DB, $USER;
431         // The import code is used for inserting data into the grade tables.
432         $this->importcode = $formdata->importcode;
433         $this->status = true;
434         $this->headers = $header;
435         $this->studentid = null;
436         $this->gradebookerrors = null;
437         $forceimport = $formdata->forceimport;
438         // Temporary array to keep track of what new headers are processed.
439         $this->newgradeitems = array();
440         $this->trim_headers();
441         $timeexportkey = null;
442         $map = array();
443         // Loops mapping_0, mapping_1 .. mapping_n and construct $map array.
444         foreach ($header as $i => $head) {
445             if (isset($formdata->{'mapping_'.$i})) {
446                 $map[$i] = $formdata->{'mapping_'.$i};
447             }
448             if ($head == get_string('timeexported', 'gradeexport_txt')) {
449                 $timeexportkey = $i;
450             }
451         }
453         // If mapping information is supplied.
454         $map[clean_param($formdata->mapfrom, PARAM_RAW)] = clean_param($formdata->mapto, PARAM_RAW);
456         // Check for mapto collisions.
457         $maperrors = array();
458         foreach ($map as $i => $j) {
459             if ($j == 0) {
460                 // You can have multiple ignores.
461                 continue;
462             } else {
463                 if (!isset($maperrors[$j])) {
464                     $maperrors[$j] = true;
465                 } else {
466                     // Collision.
467                     print_error('cannotmapfield', '', '', $j);
468                 }
469             }
470         }
472         $this->raise_limits();
474         $csvimport->init();
476         while ($line = $csvimport->next()) {
477             if (count($line) <= 1) {
478                 // There is no data on this line, move on.
479                 continue;
480             }
482             // Array to hold all grades to be inserted.
483             $this->newgrades = array();
484             // Array to hold all feedback.
485             $this->newfeedbacks = array();
486             // Each line is a student record.
487             foreach ($line as $key => $value) {
489                 $value = clean_param($value, PARAM_RAW);
490                 $value = trim($value);
492                 /*
493                  * the options are
494                  * 1) userid, useridnumber, usermail, username - used to identify user row
495                  * 2) new - new grade item
496                  * 3) id - id of the old grade item to map onto
497                  * 3) feedback_id - feedback for grade item id
498                  */
500                 // Explode the mapping for feedback into a label 'feedback' and the identifying number.
501                 $mappingbase = explode("_", $map[$key]);
502                 $mappingidentifier = $mappingbase[0];
503                 // Set the feedback identifier if it exists.
504                 if (isset($mappingbase[1])) {
505                     $feedbackgradeid = (int)$mappingbase[1];
506                 } else {
507                     $feedbackgradeid = '';
508                 }
510                 $this->map_user_data_with_value($mappingidentifier, $value, $header, $map, $key, $courseid, $feedbackgradeid,
511                         $verbosescales);
512                 if ($this->status === false) {
513                     return $this->status;
514                 }
515             }
517             // No user mapping supplied at all, or user mapping failed.
518             if (empty($this->studentid) || !is_numeric($this->studentid)) {
519                 // User not found, abort whole import.
520                 $this->cleanup_import(get_string('usermappingerrorusernotfound', 'grades'));
521                 break;
522             }
524             if ($separatemode and !groups_is_member($currentgroup, $this->studentid)) {
525                 // Not allowed to import into this group, abort.
526                 $this->cleanup_import(get_string('usermappingerrorcurrentgroup', 'grades'));
527                 break;
528             }
530             // Insert results of this students into buffer.
531             if ($this->status and !empty($this->newgrades)) {
533                 foreach ($this->newgrades as $newgrade) {
535                     // Check if grade_grade is locked and if so, abort.
536                     if (!empty($newgrade->itemid) and $gradegrade = new grade_grade(array('itemid' => $newgrade->itemid,
537                             'userid' => $this->studentid))) {
538                         if ($gradegrade->is_locked()) {
539                             // Individual grade locked.
540                             $this->cleanup_import(get_string('gradelocked', 'grades'));
541                             return $this->status;
542                         }
543                         // Check if the force import option is disabled and the last exported date column is present.
544                         if (!$forceimport && !empty($timeexportkey)) {
545                             $exportedtime = $line[$timeexportkey];
546                             if (clean_param($exportedtime, PARAM_INT) != $exportedtime || $exportedtime > time() ||
547                                     $exportedtime < strtotime("-1 year", time())) {
548                                 // The date is invalid, or in the future, or more than a year old.
549                                 $this->cleanup_import(get_string('invalidgradeexporteddate', 'grades'));
550                                 return $this->status;
552                             }
553                             $timemodified = $gradegrade->get_dategraded();
554                             if (!empty($timemodified) && ($exportedtime < $timemodified)) {
555                                 // The item was graded after we exported it, we return here not to override it.
556                                 $user = core_user::get_user($this->studentid);
557                                 $this->cleanup_import(get_string('gradealreadyupdated', 'grades', fullname($user)));
558                                 return $this->status;
559                             }
560                         }
561                     }
562                     $insertid = self::insert_grade_record($newgrade, $this->studentid);
563                     // Check to see if the insert was successful.
564                     if (empty($insertid)) {
565                         return null;
566                     }
567                 }
568             }
570             // Updating/inserting all comments here.
571             if ($this->status and !empty($this->newfeedbacks)) {
572                 foreach ($this->newfeedbacks as $newfeedback) {
573                     $sql = "SELECT *
574                               FROM {grade_import_values}
575                              WHERE importcode=? AND userid=? AND itemid=? AND importer=?";
576                     if ($feedback = $DB->get_record_sql($sql, array($this->importcode, $this->studentid, $newfeedback->itemid,
577                             $USER->id))) {
578                         $newfeedback->id = $feedback->id;
579                         $DB->update_record('grade_import_values', $newfeedback);
581                     } else {
582                         // The grade item for this is not updated.
583                         $newfeedback->importonlyfeedback = true;
584                         $insertid = self::insert_grade_record($newfeedback, $this->studentid);
585                         // Check to see if the insert was successful.
586                         if (empty($insertid)) {
587                             return null;
588                         }
589                     }
590                 }
591             }
592         }
593         return $this->status;
594     }
596     /**
597      * Returns the headers parameter for this class.
598      *
599      * @return array returns headers parameter for this class.
600      */
601     public function get_headers() {
602         return $this->headers;
603     }
605     /**
606      * Returns the error parameter for this class.
607      *
608      * @return string returns error parameter for this class.
609      */
610     public function get_error() {
611         return $this->error;
612     }
614     /**
615      * Returns the iid parameter for this class.
616      *
617      * @return int returns iid parameter for this class.
618      */
619     public function get_iid() {
620         return $this->iid;
621     }
623     /**
624      * Returns the preview_data parameter for this class.
625      *
626      * @return array returns previewdata parameter for this class.
627      */
628     public function get_previewdata() {
629         return $this->previewdata;
630     }
632     /**
633      * Returns the gradebookerrors parameter for this class.
634      *
635      * @return array returns gradebookerrors parameter for this class.
636      */
637     public function get_gradebookerrors() {
638         return $this->gradebookerrors;
639     }