MDL-40084 mod_data: Add file import support
Co-authored-by: Stefan Hanauska <[email protected]>
This commit is contained in:
co-authored by
Stefan Hanauska
parent
e11b3867ae
commit
cf2c91dae2
@@ -19,11 +19,10 @@ namespace mod_data\local;
|
||||
use coding_exception;
|
||||
use core_php_time_limit;
|
||||
use file_packer;
|
||||
use file_serving_exception;
|
||||
use zip_archive;
|
||||
use moodle_exception;
|
||||
|
||||
/**
|
||||
* Importer class for importing data.
|
||||
* Importer class for importing data and - if needed - files as well from a zip archive.
|
||||
*
|
||||
* @package mod_data
|
||||
* @copyright 2023 ISB Bayern
|
||||
@@ -41,10 +40,20 @@ abstract class importer {
|
||||
/** @var string $importfiletype The file type of the import file. */
|
||||
protected string $importfiletype;
|
||||
|
||||
/** @var file_packer Zip file packer to extract files from a zip archive. */
|
||||
private file_packer $packer;
|
||||
|
||||
/** @var bool Tracks state if zip archive has been extracted already. */
|
||||
private bool $zipfileextracted;
|
||||
|
||||
/** @var string Temporary directory where zip archive is being extracted to. */
|
||||
private string $extracteddir;
|
||||
|
||||
/**
|
||||
* Creates an importer object.
|
||||
*
|
||||
* This object can be used to import data from data files (like csv).
|
||||
* This object can be used to import data from data files (like csv) and zip archives both including a data file and files to be
|
||||
* stored in the course module context.
|
||||
*
|
||||
* @param string $importfilepath the complete path of the import file including filename
|
||||
* @param string $importfilename the import file name as uploaded by the user
|
||||
@@ -54,8 +63,10 @@ abstract class importer {
|
||||
$this->importfilepath = $importfilepath;
|
||||
$this->importfilename = $importfilename;
|
||||
$this->importfiletype = pathinfo($importfilename, PATHINFO_EXTENSION);
|
||||
if ($this->importfiletype !== $this->get_import_data_file_extension()) {
|
||||
throw new coding_exception('Only ' . $this->get_import_data_file_extension() . '" files are allowed.');
|
||||
$this->zipfileextracted = false;
|
||||
if ($this->importfiletype !== $this->get_import_data_file_extension() && $this->importfiletype !== 'zip') {
|
||||
throw new coding_exception('Only "zip" or "' . $this->get_import_data_file_extension() . '" files are '
|
||||
. 'allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,9 +80,68 @@ abstract class importer {
|
||||
/**
|
||||
* Returns the file content of the data file.
|
||||
*
|
||||
* Returns the content of the file directly if the importer's file is a data file itself. If the importer's file is a zip
|
||||
* archive, the content of the first found data file in the zip archive's root will be returned.
|
||||
*
|
||||
* @return false|string the data file content as string; false, if file cannot be found/read
|
||||
* @throws moodle_exception
|
||||
*/
|
||||
public function get_data_file_content(): false|string {
|
||||
return file_get_contents($this->importfilepath);
|
||||
if ($this->importfiletype !== 'zip') {
|
||||
// We have no zip archive, so the file itself must be the data file.
|
||||
return file_get_contents($this->importfilepath);
|
||||
}
|
||||
|
||||
// So we have a zip archive and need to find the right data file in the root of the zip archive.
|
||||
$this->extract_zip();
|
||||
$datafilenames = array_filter($this->packer->list_files($this->importfilepath),
|
||||
fn($file) => pathinfo($file->pathname, PATHINFO_EXTENSION) === $this->get_import_data_file_extension()
|
||||
&& !str_contains($file->pathname, '/'));
|
||||
if (empty($datafilenames) || count($datafilenames) > 1) {
|
||||
return false;
|
||||
}
|
||||
return file_get_contents($this->extracteddir . reset($datafilenames)->pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file content from a file which has been stored in the zip archive.
|
||||
*
|
||||
* @param string $filename
|
||||
* @param string $zipsubdir
|
||||
* @return false|string the file content as string, false if the file could not be found/read
|
||||
* @throws moodle_exception
|
||||
*/
|
||||
public function get_file_content_from_zip(string $filename, string $zipsubdir = 'files/'): false|string {
|
||||
if (empty($filename)) {
|
||||
// Nothing to return.
|
||||
return false;
|
||||
}
|
||||
// Just to be sure extract if not extracted yet.
|
||||
$this->extract_zip();
|
||||
if (!str_ends_with($zipsubdir, '/')) {
|
||||
$zipsubdir .= '/';
|
||||
}
|
||||
$filepathinextractedzip = $this->extracteddir . $zipsubdir . $filename;
|
||||
return file_exists($filepathinextractedzip) ? file_get_contents($filepathinextractedzip) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts (if not already done and if we have a zip file to deal with) the zip file to a temporary directory.
|
||||
*
|
||||
* @return void
|
||||
* @throws moodle_exception
|
||||
*/
|
||||
private function extract_zip(): void {
|
||||
if ($this->zipfileextracted || $this->importfiletype !== 'zip') {
|
||||
return;
|
||||
}
|
||||
$this->packer = get_file_packer();
|
||||
core_php_time_limit::raise(180);
|
||||
$this->extracteddir = make_request_directory();
|
||||
if (!str_ends_with($this->extracteddir, '/')) {
|
||||
$this->extracteddir .= '/';
|
||||
}
|
||||
$this->packer->extract_to_pathname($this->importfilepath, $this->extracteddir);
|
||||
$this->zipfileextracted = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ use moodle_exception;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* CSV importer class for importing data.
|
||||
* CSV importer class for importing data and - if needed - files as well from a zip archive.
|
||||
*
|
||||
* @package mod_data
|
||||
* @copyright 2023 ISB Bayern
|
||||
@@ -143,7 +143,17 @@ class mod_data_csv_importer extends csv_importer {
|
||||
$content->fieldid = $field->field->id;
|
||||
$content->content = $value;
|
||||
$content->recordid = $recordid;
|
||||
$DB->insert_record('data_content', $content);
|
||||
if ($field->file_import_supported() && $this->importfiletype === 'zip') {
|
||||
$filecontent = $this->get_file_content_from_zip($content->content);
|
||||
if (!$filecontent) {
|
||||
// No corresponding file in zip archive, so no record for this field being added at all.
|
||||
continue;
|
||||
}
|
||||
$contentid = $DB->insert_record('data_content', $content);
|
||||
$field->import_file_value($contentid, $filecontent, $content->content);
|
||||
} else {
|
||||
$DB->insert_record('data_content', $content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -246,6 +246,38 @@ class data_field_file extends data_field_base {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies that this field type supports the import of files.
|
||||
*
|
||||
* @return bool true which means that file import is being supported by this field type
|
||||
*/
|
||||
public function file_import_supported(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the necessary code for importing a file when importing the content of a mod_data instance.
|
||||
*
|
||||
* @param int $contentid the id of the mod_data content record
|
||||
* @param string $filecontent the content of the file to import as string
|
||||
* @param string $filename the filename the imported file should get
|
||||
* @return void
|
||||
* @throws file_exception
|
||||
* @throws stored_file_creation_exception
|
||||
*/
|
||||
public function import_file_value(int $contentid, string $filecontent, string $filename): void {
|
||||
$filerecord = [
|
||||
'contextid' => $this->context->id,
|
||||
'component' => 'mod_data',
|
||||
'filearea' => 'content',
|
||||
'itemid' => $contentid,
|
||||
'filepath' => '/',
|
||||
'filename' => $filename,
|
||||
];
|
||||
$fs = get_file_storage();
|
||||
$fs->create_file_from_string($filerecord, $filecontent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the file content for file export.
|
||||
*
|
||||
|
||||
@@ -391,6 +391,39 @@ class data_field_picture extends data_field_base {
|
||||
return $file ? $file->get_content() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies that this field type supports the import of files.
|
||||
*
|
||||
* @return bool true which means that file import is being supported by this field type
|
||||
*/
|
||||
public function file_import_supported(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the necessary code for importing a file when importing the content of a mod_data instance.
|
||||
*
|
||||
* @param int $contentid the id of the mod_data content record
|
||||
* @param string $filecontent the content of the file to import as string
|
||||
* @param string $filename the filename the imported file should get
|
||||
* @return void
|
||||
* @throws file_exception
|
||||
* @throws stored_file_creation_exception
|
||||
*/
|
||||
public function import_file_value(int $contentid, string $filecontent, string $filename): void {
|
||||
$filerecord = [
|
||||
'contextid' => $this->context->id,
|
||||
'component' => 'mod_data',
|
||||
'filearea' => 'content',
|
||||
'itemid' => $contentid,
|
||||
'filepath' => '/',
|
||||
'filename' => $filename,
|
||||
];
|
||||
$fs = get_file_storage();
|
||||
$file = $fs->create_file_from_string($filerecord, $filecontent);
|
||||
$this->update_thumbnail(null, $file);
|
||||
}
|
||||
|
||||
function file_ok($path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ class mod_data_import_form extends moodleform {
|
||||
$dataid = $this->_customdata['dataid'];
|
||||
$backtourl = $this->_customdata['backtourl'];
|
||||
|
||||
$mform->addElement('filepicker', 'recordsfile', get_string('csvfile', 'data'));
|
||||
$mform->addElement('filepicker', 'recordsfile', get_string('csvorzipfile', 'data'),
|
||||
null, ['accepted_types' => ['application/zip', 'text/csv']]);
|
||||
|
||||
$delimiters = csv_import_reader::get_delimiter_list();
|
||||
$mform->addElement('select', 'fielddelimiter', get_string('fielddelimiter', 'data'), $delimiters);
|
||||
|
||||
@@ -83,7 +83,7 @@ $string['createfields'] = 'Create fields to collect different types of data.';
|
||||
$string['createtemplates'] = 'Templates define the interface of your activity. Once you create fields, templates will be created automatically. Alternatively, you can use a preset, which includes fields and templates.';
|
||||
$string['csstemplate'] = 'Custom CSS';
|
||||
$string['csvfailed'] = 'Unable to read the raw data from the CSV file';
|
||||
$string['csvfile'] = 'CSV file';
|
||||
$string['csvorzipfile'] = 'CSV or ZIP containing a CSV file';
|
||||
$string['csvimport'] = 'CSV file import';
|
||||
$string['csvimport_help'] = 'Entries may be imported via a plain text file with a list of field names as the first line, then the data, with one record per line.';
|
||||
$string['csvwithselecteddelimiter'] = '<abbr title="Comma Separated Values">CSV</abbr>';
|
||||
@@ -152,7 +152,7 @@ $string['entrieslefttoadd'] = 'You must add {$a->entriesleft} more entry/entries
|
||||
$string['entrieslefttoaddtoview'] = 'You must add {$a->entrieslefttoview} more entry/entries before you can view other participants\' entries.';
|
||||
$string['entry'] = 'Entry';
|
||||
$string['entrysaved'] = 'Your entry has been saved';
|
||||
$string['errordatafilenotfound'] = 'The file could not be imported. Please upload a CSV file.';
|
||||
$string['errordatafilenotfound'] = 'The file could not be imported. Accepted file types are CSV or a ZIP containing a CSV file in the format used for exporting entries.';
|
||||
$string['errormustbeteacher'] = 'You need to be a teacher to use this page!';
|
||||
$string['errorpresetexists'] = 'A preset with this name already exists.';
|
||||
$string['errorpresetexistsbutnotoverwrite'] = 'A preset with this name already exists. Choose a different name.';
|
||||
@@ -455,14 +455,10 @@ $string['unsupportedfields'] = 'Unsupported fields';
|
||||
$string['unsupportedfieldslist'] = 'The following fields cannot be exported:';
|
||||
$string['updatefield'] = 'Update an existing field';
|
||||
$string['uploadfile'] = 'Upload file';
|
||||
$string['uploadrecords'] = 'Upload entries from a file';
|
||||
$string['uploadrecords_help'] = 'Entries may be uploaded via text file. The format of the file should be as follows:
|
||||
$string['uploadrecords'] = 'Import entries';
|
||||
$string['uploadrecords_help'] = 'Import entries that you have exported from another database, either via CSV or a ZIP containing a CSV file (if files are included in the export).
|
||||
|
||||
* Each line of the file contains one record
|
||||
* Each record is a series of data separated by the selected separator
|
||||
* The first record contains a list of fieldnames defining the format of the rest of the file
|
||||
|
||||
The field enclosure is a character that surrounds each field in each record. It can normally be left unset.';
|
||||
Alternatively, to create a CSV file for importing, add one entry to the database and then export it. Edit the CSV file and add more entries.';
|
||||
$string['uploadrecords_link'] = 'mod/data/import';
|
||||
$string['url'] = 'URL';
|
||||
$string['usedate'] = 'Include in search.';
|
||||
|
||||
@@ -661,6 +661,29 @@ class data_field_base { // Base class for Database Field Types (see field/*/
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per default, a field does not support the import of files.
|
||||
*
|
||||
* A field type can overwrite this function and return true. In this case it also has to implement the function
|
||||
* import_file_value().
|
||||
*
|
||||
* @return false means file imports are not supported
|
||||
*/
|
||||
public function file_import_supported(): bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stored_file object for exporting a file of a given record.
|
||||
*
|
||||
* @param int $contentid content id
|
||||
* @param string $filecontent the content of the file as string
|
||||
* @param string $filename the filename the file should have
|
||||
*/
|
||||
public function import_file_value(int $contentid, string $filecontent, string $filename): void {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per default, return the record's text value only from the "content" field.
|
||||
* Override this in fields class if necessary.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -20,6 +20,7 @@ use coding_exception;
|
||||
use dml_exception;
|
||||
use mod_data\local\mod_data_csv_importer;
|
||||
use moodle_exception;
|
||||
use zip_archive;
|
||||
|
||||
/**
|
||||
* Unit tests for import.php.
|
||||
@@ -73,6 +74,13 @@ class import_test extends \advanced_testcase {
|
||||
$fieldrecord->type = 'text';
|
||||
$generator->create_field($fieldrecord, $data);
|
||||
|
||||
$fieldrecord->name = 'filefield';
|
||||
$fieldrecord->type = 'file';
|
||||
$generator->create_field($fieldrecord, $data);
|
||||
|
||||
$fieldrecord->name = 'picturefield';
|
||||
$fieldrecord->type = 'picture';
|
||||
$generator->create_field($fieldrecord, $data);
|
||||
|
||||
return [
|
||||
'teacher' => $teacher,
|
||||
@@ -116,8 +124,6 @@ class import_test extends \advanced_testcase {
|
||||
*
|
||||
* At least one entry has an identifiable user, which is assigned as author.
|
||||
*
|
||||
* @throws coding_exception
|
||||
* @throws moodle_exception
|
||||
* @throws dml_exception
|
||||
*/
|
||||
public function test_import_with_userdata(): void {
|
||||
@@ -151,7 +157,6 @@ class import_test extends \advanced_testcase {
|
||||
* as the current lang string for username. In that case, the first Username entry is used for the field.
|
||||
* The second one is used to identify the author.
|
||||
*
|
||||
* @throws moodle_exception
|
||||
* @throws coding_exception
|
||||
* @throws dml_exception
|
||||
*/
|
||||
@@ -214,8 +219,8 @@ class import_test extends \advanced_testcase {
|
||||
* as the current lang string for username. In that case, the only Username entry is used for the field.
|
||||
* The author should not be set.
|
||||
*
|
||||
* @throws coding_exception
|
||||
* @throws dml_exception
|
||||
* @throws moodle_exception
|
||||
*/
|
||||
public function test_import_with_field_username_without_userdata(): void {
|
||||
[
|
||||
@@ -265,11 +270,98 @@ class import_test extends \advanced_testcase {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the import including files from a zip archive.
|
||||
*
|
||||
* @covers \mod_data\local\importer
|
||||
* @covers \mod_data\local\csv_importer
|
||||
* @return void
|
||||
* @throws coding_exception
|
||||
* @throws moodle_exception
|
||||
* @throws dml_exception
|
||||
*/
|
||||
public function test_import_with_files(): void {
|
||||
[
|
||||
'data' => $data,
|
||||
'cm' => $cm,
|
||||
] = $this->get_test_data();
|
||||
|
||||
$importer = new mod_data_csv_importer(__DIR__ . '/fixtures/test_data_import_with_files.zip',
|
||||
'test_data_import_with_files.zip');
|
||||
$importer->import_csv($cm, $data, 'UTF-8', 'comma');
|
||||
|
||||
$records = $this->get_data_records($data->id);
|
||||
$ziparchive = new zip_archive();
|
||||
$ziparchive->open(__DIR__ . '/fixtures/test_data_import_with_files.zip');
|
||||
|
||||
$importedcontent = array_values($records)[0]->items;
|
||||
$this->assertEquals(17, $importedcontent['ID']->content);
|
||||
$this->assertEquals('samplefile.png', $importedcontent['filefield']->content);
|
||||
$this->assertEquals('samplepicture.png', $importedcontent['picturefield']->content);
|
||||
|
||||
// We now check if content of imported file from zip content is identical to the content of the file
|
||||
// stored in the mod_data record in the field 'filefield'.
|
||||
$fileindex = array_values(array_map(fn($file) => $file->index,
|
||||
array_filter($ziparchive->list_files(), fn($file) => $file->pathname === 'files/samplefile.png')))[0];
|
||||
$filestream = $ziparchive->get_stream($fileindex);
|
||||
$filefield = data_get_field_from_name('filefield', $data);
|
||||
$filefieldfilecontent = fread($filestream, $ziparchive->get_info($fileindex)->size);
|
||||
$this->assertEquals($filefield->get_file(array_keys($records)[0])->get_content(),
|
||||
$filefieldfilecontent);
|
||||
fclose($filestream);
|
||||
|
||||
// We now check if content of imported picture from zip content is identical to the content of the picture file
|
||||
// stored in the mod_data record in the field 'picturefield'.
|
||||
$fileindex = array_values(array_map(fn($file) => $file->index,
|
||||
array_filter($ziparchive->list_files(), fn($file) => $file->pathname === 'files/samplepicture.png')))[0];
|
||||
$filestream = $ziparchive->get_stream($fileindex);
|
||||
$filefield = data_get_field_from_name('picturefield', $data);
|
||||
$filefieldfilecontent = fread($filestream, $ziparchive->get_info($fileindex)->size);
|
||||
$this->assertEquals($filefield->get_file(array_keys($records)[0])->get_content(),
|
||||
$filefieldfilecontent);
|
||||
fclose($filestream);
|
||||
|
||||
$ziparchive->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the import including files from a zip archive.
|
||||
*
|
||||
* @covers \mod_data\local\importer
|
||||
* @covers \mod_data\local\csv_importer
|
||||
* @return void
|
||||
* @throws coding_exception
|
||||
* @throws moodle_exception
|
||||
* @throws dml_exception
|
||||
*/
|
||||
public function test_import_with_files_missing_file(): void {
|
||||
[
|
||||
'data' => $data,
|
||||
'cm' => $cm,
|
||||
] = $this->get_test_data();
|
||||
|
||||
$importer = new mod_data_csv_importer(__DIR__ . '/fixtures/test_data_import_with_files_missing_file.zip',
|
||||
'test_data_import_with_files_missing_file.zip');
|
||||
$importer->import_csv($cm, $data, 'UTF-8', 'comma');
|
||||
|
||||
$records = $this->get_data_records($data->id);
|
||||
$ziparchive = new zip_archive();
|
||||
$ziparchive->open(__DIR__ . '/fixtures/test_data_import_with_files_missing_file.zip');
|
||||
|
||||
$importedcontent = array_values($records)[0]->items;
|
||||
$this->assertEquals(17, $importedcontent['ID']->content);
|
||||
$this->assertFalse(isset($importedcontent['filefield']));
|
||||
$this->assertEquals('samplepicture.png', $importedcontent['picturefield']->content);
|
||||
|
||||
$ziparchive->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the records of the data instance.
|
||||
*
|
||||
* Each records has an item entry, which contains all fields associated with this item.
|
||||
* Each fields has the parameters name, type and content.
|
||||
*
|
||||
* @param int $dataid Id of the data instance.
|
||||
* @return array The records of the data instance.
|
||||
* @throws dml_exception
|
||||
|
||||
@@ -16,6 +16,10 @@ information provided here is intended especially for developers.
|
||||
type will have to export the name of the file by overwriting text_export_supported() to return true and make the
|
||||
method export_text_value() return the name of the file.
|
||||
* The field types file and picture now are able to export the file/picture.
|
||||
* Field base class now has two new methods file_import_supported() and import_file_value(). The method
|
||||
file_import_supported() can be overwritten to declare that a field type is able to import a file. In this case this
|
||||
field type will have to implement the method import_file_value() doing the actual import of the file being passed.
|
||||
* The field types file and picture now are able to import the file/picture.
|
||||
|
||||
== 4.2 ==
|
||||
* The field base class now has a method validate(). Overwrite it in the field type to provide validation of field type's
|
||||
|
||||
Reference in New Issue
Block a user