mirror of
https://github.com/moodle/moodle.git
synced 2025-04-21 00:12:56 +02:00
MDL-40084 mod_data: Add file export support
This commit is contained in:
parent
1296a2b868
commit
4003751163
@ -17,10 +17,11 @@
|
||||
namespace mod_data\local;
|
||||
|
||||
use file_serving_exception;
|
||||
use moodle_exception;
|
||||
use zip_archive;
|
||||
|
||||
/**
|
||||
* Exporter class for exporting data.
|
||||
* Exporter class for exporting data and - if needed - files as well in a zip archive.
|
||||
*
|
||||
* @package mod_data
|
||||
* @copyright 2023 ISB Bayern
|
||||
@ -41,15 +42,30 @@ abstract class exporter {
|
||||
/** @var string Name of the export file name without extension. */
|
||||
protected string $exportfilename;
|
||||
|
||||
/** @var zip_archive The zip archive object we store all the files in, if we need to export files as well. */
|
||||
private zip_archive $ziparchive;
|
||||
|
||||
/** @var bool Tracks the state if the zip archive already has been closed. */
|
||||
private bool $ziparchiveclosed;
|
||||
|
||||
/** @var string full path of the zip archive. */
|
||||
private string $zipfilepath;
|
||||
|
||||
/** @var array Array to store all filenames in the zip archive for export. */
|
||||
private array $filenamesinzip;
|
||||
|
||||
/**
|
||||
* Creates an exporter object.
|
||||
*
|
||||
* This object can be used to export data to different formats.
|
||||
* This object can be used to export data to different formats including files. If files are added,
|
||||
* everything will be bundled up in a zip archive.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->currentrow = 0;
|
||||
$this->exportdata = [];
|
||||
$this->exportfilename = 'Exportfile';
|
||||
$this->filenamesinzip = [];
|
||||
$this->ziparchiveclosed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -124,6 +140,28 @@ abstract class exporter {
|
||||
return count($this->exportdata) - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this method to add a file which should be exported to the exporter.
|
||||
*
|
||||
* @param string $filename the name of the file which should be added
|
||||
* @param string $filecontent the content of the file as a string
|
||||
* @param string $zipsubdir the subdirectory in the zip archive. Defaults to 'files/'.
|
||||
* @return void
|
||||
* @throws moodle_exception if there is an error adding the file to the zip archive
|
||||
*/
|
||||
public function add_file_from_string(string $filename, string $filecontent, string $zipsubdir = 'files/'): void {
|
||||
if (empty($this->filenamesinzip)) {
|
||||
// No files added yet, so we need to create a zip archive.
|
||||
$this->create_zip_archive();
|
||||
}
|
||||
if (!str_ends_with($zipsubdir, '/')) {
|
||||
$zipsubdir .= '/';
|
||||
}
|
||||
$zipfilename = $zipsubdir . $filename;
|
||||
$this->filenamesinzip[] = $zipfilename;
|
||||
$this->ziparchive->add_file_from_string($zipfilename, $filecontent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the generated export file.
|
||||
*
|
||||
@ -132,6 +170,8 @@ abstract class exporter {
|
||||
* @param bool $sendtouser true if the file should be sent directly to the user, if false the file content will be returned
|
||||
* as string
|
||||
* @return string|null file content as string if $sendtouser is true
|
||||
* @throws moodle_exception if there is an issue adding the data file
|
||||
* @throws file_serving_exception if the file could not be served properly
|
||||
*/
|
||||
public function send_file(bool $sendtouser = true): null|string {
|
||||
if (empty($this->filenamesinzip)) {
|
||||
@ -144,6 +184,85 @@ abstract class exporter {
|
||||
return $this->get_data_file_content();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
$this->add_file_from_string($this->exportfilename . '.' . $this->get_export_data_file_extension(),
|
||||
$this->get_data_file_content(), '/');
|
||||
$this->finish_zip_archive();
|
||||
|
||||
if ($this->ziparchiveclosed) {
|
||||
if ($sendtouser) {
|
||||
send_file($this->zipfilepath, $this->exportfilename . '.zip', null, 0, false, true);
|
||||
return null;
|
||||
} else {
|
||||
return file_get_contents($this->zipfilepath);
|
||||
}
|
||||
} else {
|
||||
throw new file_serving_exception('Could not serve zip file, it could not be closed properly.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file with the given name has already been added to the file export bundle.
|
||||
*
|
||||
* Care: Filenames are compared to all files in the specified zip subdirectory which
|
||||
* defaults to 'files/'.
|
||||
*
|
||||
* @param string $filename the filename containing the zip path of the file to check
|
||||
* @param string $zipsubdir The subdirectory in which the filename should be looked for,
|
||||
* defaults to 'files/'
|
||||
* @return bool true if file with the given name already exists, false otherwise
|
||||
*/
|
||||
public function file_exists(string $filename, string $zipsubdir = 'files/'): bool {
|
||||
if (!str_ends_with($zipsubdir, '/')) {
|
||||
$zipsubdir .= '/';
|
||||
}
|
||||
return in_array($zipsubdir . $filename, $this->filenamesinzip, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a unique filename based on the given filename.
|
||||
*
|
||||
* This method adds "_1", "_2", ... to the given file name until the newly generated filename
|
||||
* is not equal to any of the already saved ones in the export file bundle.
|
||||
*
|
||||
* @param string $filename the filename based on which a unique filename should be generated
|
||||
* @return string the unique filename
|
||||
*/
|
||||
public function create_unique_filename(string $filename): string {
|
||||
if (!$this->file_exists($filename)) {
|
||||
return $filename;
|
||||
}
|
||||
$i = 1;
|
||||
|
||||
while ($this->file_exists($filename)) {
|
||||
$extension = pathinfo($filename, PATHINFO_EXTENSION);
|
||||
$filenamewithoutextension = substr($filename, 0,
|
||||
strlen($filename) - strlen($extension) - 1);
|
||||
$filename = $filenamewithoutextension . '_' . $i . '.' . $extension;
|
||||
$i++;
|
||||
}
|
||||
return $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the zip archive.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function create_zip_archive(): void {
|
||||
$tmpdir = make_request_directory();
|
||||
$this->zipfilepath = $tmpdir . '/' . $this->exportfilename . '.zip';
|
||||
$this->ziparchive = new zip_archive();
|
||||
$this->ziparchiveclosed = !$this->ziparchive->open($this->zipfilepath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the zip archive.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function finish_zip_archive(): void {
|
||||
if (!$this->ziparchiveclosed) {
|
||||
$this->ziparchiveclosed = $this->ziparchive->close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -46,6 +46,7 @@ class exporter_utils {
|
||||
* @param bool $time whether to include time created/modified
|
||||
* @param bool $approval whether to include approval status
|
||||
* @param bool $tags whether to include tags
|
||||
* @param bool $includefiles whether files should be exported as well
|
||||
* @return void
|
||||
* @throws coding_exception
|
||||
* @throws dml_exception
|
||||
@ -53,7 +54,7 @@ class exporter_utils {
|
||||
*/
|
||||
public static function data_exportdata(int $dataid, array $fields, array $selectedfields, exporter $exporter,
|
||||
int $currentgroup = 0, context $context = null, bool $userdetails = false, bool $time = false, bool $approval = false,
|
||||
bool $tags = false): void {
|
||||
bool $tags = false, bool $includefiles = true): void {
|
||||
global $DB;
|
||||
|
||||
if (is_null($context)) {
|
||||
@ -108,6 +109,15 @@ class exporter_utils {
|
||||
$contents = '';
|
||||
if (isset($content[$field->field->id])) {
|
||||
$contents = $field->export_text_value($content[$field->field->id]);
|
||||
if (!empty($contents) && $field->file_export_supported() && $includefiles
|
||||
&& !is_null($field->export_file_value($record))) {
|
||||
// For exporting overwrite the content of the column with a unique
|
||||
// filename, even it is not exactly the name of the file in the
|
||||
// mod_data instance content. But it's more important to match the name
|
||||
// of the exported file.
|
||||
$contents = $exporter->create_unique_filename($contents);
|
||||
$exporter->add_file_from_string($contents, $field->export_file_value($record));
|
||||
}
|
||||
}
|
||||
// Just be double sure.
|
||||
$contents = !empty($contents) ? $contents : '';
|
||||
|
@ -109,8 +109,9 @@ if ($mform->is_cancelled()) {
|
||||
. 'Only "csv" and "ods" are currently supported.');
|
||||
}
|
||||
|
||||
$includefiles = !empty($formdata['includefiles']);
|
||||
\mod_data\local\exporter_utils::data_exportdata($data->id, $fields, $selectedfields, $exporter, $currentgroup, $context,
|
||||
$exportuser, $exporttime, $exportapproval, $tags);
|
||||
$exportuser, $exporttime, $exportapproval, $tags, $includefiles);
|
||||
$count = $exporter->get_records_count();
|
||||
$filename = clean_filename("{$data->name}-{$count}_record");
|
||||
if ($count > 1) {
|
||||
|
@ -112,6 +112,8 @@ class mod_data_export_form extends moodleform {
|
||||
$exportoptions[] = $mform->createElement('checkbox', 'exportapproval', get_string('includeapproval', 'data'), '',
|
||||
$optionattrs);
|
||||
}
|
||||
$exportoptions[] = $mform->createElement('checkbox', 'includefiles', get_string('includefiles', 'data'), '', $optionattrs);
|
||||
$mform->setDefault('includefiles', 1);
|
||||
$mform->addGroup($exportoptions, 'exportoptions', get_string('selectexportoptions', 'data'), ['<br>'], false);
|
||||
|
||||
$this->add_action_buttons(true, get_string('exportentries', 'data'));
|
||||
|
@ -218,8 +218,43 @@ class data_field_file extends data_field_base {
|
||||
$DB->update_record('data_content', $content);
|
||||
}
|
||||
|
||||
function text_export_supported() {
|
||||
return false;
|
||||
/**
|
||||
* File field supports export of text. The text being exported is the filename of the stored file.
|
||||
*
|
||||
* @return bool true
|
||||
*/
|
||||
public function text_export_supported() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Here we export the text value of a file field which is the filename of the exported file.
|
||||
*
|
||||
* @param stdClass $record the record which is being exported
|
||||
* @return string the value which will be stored in the exported file for this field
|
||||
*/
|
||||
public function export_text_value(stdClass $record): string {
|
||||
return !empty($record->content) ? $record->content : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies that this field type supports the export of files.
|
||||
*
|
||||
* @return bool true which means that file export is being supported by this field type
|
||||
*/
|
||||
public function file_export_supported(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the file content for file export.
|
||||
*
|
||||
* @param stdClass $record the data content record the file belongs to
|
||||
* @return null|string The file content of the stored file or null if no file should be exported for this record
|
||||
*/
|
||||
public function export_file_value(stdClass $record): null|string {
|
||||
$file = $this->get_file($record->id);
|
||||
return $file ? $file->get_content() : null;
|
||||
}
|
||||
|
||||
function file_ok($path) {
|
||||
|
@ -348,8 +348,43 @@ class data_field_picture extends data_field_base {
|
||||
}
|
||||
}
|
||||
|
||||
function text_export_supported() {
|
||||
return false;
|
||||
/**
|
||||
* Picture field supports export of text. The text being exported is the filename of the stored picture.
|
||||
*
|
||||
* @return bool true
|
||||
*/
|
||||
public function text_export_supported() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Here we export the text value of a picture field which is the filename of the exported picture.
|
||||
*
|
||||
* @param stdClass $record the record which is being exported
|
||||
* @return string the value which will be stored in the exported file for this field
|
||||
*/
|
||||
public function export_text_value(stdClass $record): string {
|
||||
return !empty($record->content) ? $record->content : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies that this field type supports the export of files.
|
||||
*
|
||||
* @return bool true which means that file export is being supported by this field type
|
||||
*/
|
||||
public function file_export_supported(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the file content for file export.
|
||||
*
|
||||
* @param stdClass $record the data content record the file belongs to
|
||||
* @return null|string The file content of the stored file or null if no file should be exported for this record
|
||||
*/
|
||||
public function export_file_value(stdClass $record): null|string {
|
||||
$file = $this->get_file($record->id);
|
||||
return $file ? $file->get_content() : null;
|
||||
}
|
||||
|
||||
function file_ok($path) {
|
||||
|
@ -228,6 +228,7 @@ $string['importapreset'] = 'Import a preset';
|
||||
$string['importsuccess'] = 'Preset applied.';
|
||||
$string['importpresetmissingcapability'] = 'You don\'t have permission to import a preset.';
|
||||
$string['includeapproval'] = 'Include approval status';
|
||||
$string['includefiles'] = 'Include files in export';
|
||||
$string['includetags'] = 'Include tags';
|
||||
$string['includetime'] = 'Include time added/modified';
|
||||
$string['includeuserdetails'] = 'Include user details';
|
||||
|
@ -640,16 +640,39 @@ class data_field_base { // Base class for Database Field Types (see field/*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Per default, return the record's text value only from the "content" field.
|
||||
* Override this in fields class if necesarry.
|
||||
* Per default, it is assumed that fields do not support file exporting. Override this (return true)
|
||||
* on fields supporting file export. You will also have to implement export_file_value().
|
||||
*
|
||||
* @param string $record
|
||||
* @return bool true if field will export a file, false otherwise
|
||||
*/
|
||||
public function file_export_supported(): bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per default, does not return a file (just null).
|
||||
* Override this in fields class, if you want your field to export a file content.
|
||||
* In case you are exporting a file value, export_text_value() should return the corresponding file name.
|
||||
*
|
||||
* @param stdClass $record
|
||||
* @return null|string the file content as string or null, if no file content is being provided
|
||||
*/
|
||||
public function export_file_value(stdClass $record): null|string {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per default, return the record's text value only from the "content" field.
|
||||
* Override this in fields class if necessary.
|
||||
*
|
||||
* @param stdClass $record
|
||||
* @return string
|
||||
*/
|
||||
function export_text_value($record) {
|
||||
public function export_text_value(stdClass $record) {
|
||||
if ($this->text_export_supported()) {
|
||||
return $record->content;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -63,8 +63,23 @@ class export_test extends \advanced_testcase {
|
||||
$fieldrecord->type = 'text';
|
||||
$textfield = $generator->create_field($fieldrecord, $data);
|
||||
|
||||
$fieldrecord->name = 'filefield1';
|
||||
$fieldrecord->type = 'file';
|
||||
$filefield1 = $generator->create_field($fieldrecord, $data);
|
||||
|
||||
$fieldrecord->name = 'filefield2';
|
||||
$fieldrecord->type = 'file';
|
||||
$filefield2 = $generator->create_field($fieldrecord, $data);
|
||||
|
||||
$fieldrecord->name = 'picturefield';
|
||||
$fieldrecord->type = 'picture';
|
||||
$picturefield = $generator->create_field($fieldrecord, $data);
|
||||
|
||||
$contents[$numberfield->field->id] = '3';
|
||||
$contents[$textfield->field->id] = 'a simple text';
|
||||
$contents[$filefield1->field->id] = 'samplefile.png';
|
||||
$contents[$filefield2->field->id] = 'samplefile.png';
|
||||
$contents[$picturefield->field->id] = ['picturefile.png', 'this picture shows something'];
|
||||
$generator->create_entry($data, $contents);
|
||||
|
||||
return [
|
||||
@ -105,10 +120,49 @@ class export_test extends \advanced_testcase {
|
||||
$exporttime = false;
|
||||
$exportapproval = false;
|
||||
$tags = false;
|
||||
|
||||
// We first test the export without exporting files.
|
||||
// This means file and picture fields will be exported, but only as text (which is the filename),
|
||||
// so we will receive a csv export file.
|
||||
$includefiles = false;
|
||||
exporter_utils::data_exportdata($data->id, $fields, $selectedfields, $exporter, $currentgroup, $context,
|
||||
$exportuser, $exporttime, $exportapproval, $tags);
|
||||
$this->assertEquals(file_get_contents(__DIR__ . '/fixtures/test_data_export.csv'),
|
||||
$exportuser, $exporttime, $exportapproval, $tags, $includefiles);
|
||||
$this->assertEquals(file_get_contents(__DIR__ . '/fixtures/test_data_export_without_files.csv'),
|
||||
$exporter->send_file(false));
|
||||
|
||||
// We now test the export including files. This will generate a zip archive.
|
||||
$includefiles = true;
|
||||
$exporter = new csv_exporter();
|
||||
$exporter->set_export_file_name('testexportfile');
|
||||
exporter_utils::data_exportdata($data->id, $fields, $selectedfields, $exporter, $currentgroup, $context,
|
||||
$exportuser, $exporttime, $exportapproval, $tags, $includefiles);
|
||||
// We now write the zip archive temporary to disc to be able to parse it and assert it has the correct structure.
|
||||
$tmpdir = make_request_directory();
|
||||
file_put_contents($tmpdir . '/testexportarchive.zip', $exporter->send_file(false));
|
||||
$ziparchive = new \zip_archive();
|
||||
$ziparchive->open($tmpdir . '/testexportarchive.zip');
|
||||
$expectedfilecontents = [
|
||||
// The test generator for mod_data uses a copy of pix/monologo.png as sample file content for the file stored in a
|
||||
// file and picture field.
|
||||
// So we expect that this file has to have the same content as monologo.png.
|
||||
// Also, the default value for the subdirectory in the zip archive containing the files is 'files/'.
|
||||
'files/samplefile.png' => 'mod/data/pix/monologo.png',
|
||||
'files/samplefile_1.png' => 'mod/data/pix/monologo.png',
|
||||
'files/picturefile.png' => 'mod/data/pix/monologo.png',
|
||||
// By checking that the content of the exported csv is identical to the fixture file it is verified
|
||||
// that the filenames in the csv file correspond to the names of the exported file.
|
||||
// It also verifies that files with identical file names in different fields (or records) will be numbered
|
||||
// automatically (samplefile.png, samplefile_1.png, ...).
|
||||
'testexportfile.csv' => __DIR__ . '/fixtures/test_data_export_with_files.csv'
|
||||
];
|
||||
for ($i = 0; $i < $ziparchive->count(); $i++) {
|
||||
// We here iterate over all files in the zip archive and check if their content is identical to the files
|
||||
// in the $expectedfilecontents array.
|
||||
$filestream = $ziparchive->get_stream($i);
|
||||
$fileinfo = $ziparchive->get_info($i);
|
||||
$filecontent = fread($filestream, $fileinfo->size);
|
||||
$this->assertEquals(file_get_contents($expectedfilecontents[$fileinfo->pathname]), $filecontent);
|
||||
fclose($filestream);
|
||||
}
|
||||
$ziparchive->close();
|
||||
}
|
||||
}
|
||||
|
2
mod/data/tests/fixtures/test_data_export.csv
vendored
2
mod/data/tests/fixtures/test_data_export.csv
vendored
@ -1,2 +0,0 @@
|
||||
numberfield,textfield
|
||||
3,"a simple text"
|
|
2
mod/data/tests/fixtures/test_data_export_with_files.csv
vendored
Normal file
2
mod/data/tests/fixtures/test_data_export_with_files.csv
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
numberfield,textfield,filefield1,filefield2,picturefield
|
||||
3,"a simple text",samplefile.png,samplefile_1.png,picturefile.png
|
|
2
mod/data/tests/fixtures/test_data_export_without_files.csv
vendored
Normal file
2
mod/data/tests/fixtures/test_data_export_without_files.csv
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
numberfield,textfield,filefield1,filefield2,picturefield
|
||||
3,"a simple text",samplefile.png,samplefile.png,picturefile.png
|
|
@ -10,6 +10,12 @@ information provided here is intended especially for developers.
|
||||
* Function data_import_csv() has been deprecated and moved to deprecatedlib due to a bigger rework of the way data is
|
||||
being imported. This is now being done by new importer class \mod_data\local\mod_data_csv_importer inheriting from new
|
||||
classes \mod_data\local\csv_importer and \mod_data\local\importer.
|
||||
* Field base class now has two new methods file_export_supported() and export_file_value(). The method
|
||||
file_export_supported() can be overwritten to declare that a field type can/wants to export a file. In this case this
|
||||
field type will have to implement the method export_file_value() returning this file for exporting. Also: This field
|
||||
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.
|
||||
|
||||
== 4.2 ==
|
||||
* The field base class now has a method validate(). Overwrite it in the field type to provide validation of field type's
|
||||
|
Loading…
x
Reference in New Issue
Block a user