MDL-40084 mod_data: Add file export support

This commit is contained in:
Philipp Memmel 2023-03-13 23:23:12 +01:00
parent 1296a2b868
commit 4003751163
13 changed files with 306 additions and 18 deletions

View File

@ -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();
}
}
}

View File

@ -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 : '';

View File

@ -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) {

View File

@ -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'));

View File

@ -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) {

View File

@ -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) {

View File

@ -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';

View File

@ -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 '';
}
/**

View File

@ -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();
}
}

View File

@ -1,2 +0,0 @@
numberfield,textfield
3,"a simple text"
1 numberfield textfield
2 3 a simple text

View File

@ -0,0 +1,2 @@
numberfield,textfield,filefield1,filefield2,picturefield
3,"a simple text",samplefile.png,samplefile_1.png,picturefile.png
1 numberfield textfield filefield1 filefield2 picturefield
2 3 a simple text samplefile.png samplefile_1.png picturefile.png

View File

@ -0,0 +1,2 @@
numberfield,textfield,filefield1,filefield2,picturefield
3,"a simple text",samplefile.png,samplefile.png,picturefile.png
1 numberfield textfield filefield1 filefield2 picturefield
2 3 a simple text samplefile.png samplefile.png picturefile.png

View File

@ -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