diff --git a/essaydownload_form.php b/essaydownload_form.php index e7f5681..0834521 100644 --- a/essaydownload_form.php +++ b/essaydownload_form.php @@ -54,8 +54,13 @@ class quiz_essaydownload_form extends moodleform { public function definition() { $mform = $this->_form; - $mform->addElement('header', 'preferencespage', get_string('options', 'quiz_essaydownload')); + $mform->addElement('header', 'generaloptions', get_string('generaloptions', 'quiz_essaydownload')); $this->standard_preference_fields($mform); + + $mform->addElement('header', 'pdfoptions', get_string('pdfoptions', 'quiz_essaydownload')); + $this->pdf_layout_fields($mform); + $mform->closeHeaderBefore('download'); + $mform->addElement('submit', 'download', get_string('download')); } @@ -78,15 +83,6 @@ protected function standard_preference_fields(MoodleQuickForm $mform) { $mform->setType('groupby', PARAM_ALPHA); $mform->addHelpButton('groupby', 'groupby', 'quiz_essaydownload'); - $mform->addElement( - 'advcheckbox', - 'questiontext', - get_string('whattoinclude', 'quiz_essaydownload'), - get_string('includequestiontext', 'quiz_essaydownload') - ); - $mform->addElement('advcheckbox', 'responsetext', get_string('includeresponsetext', 'quiz_essaydownload')); - $mform->addElement('advcheckbox', 'attachments', get_string('includeattachments', 'quiz_essaydownload')); - $mform->addElement( 'select', 'nameordering', @@ -98,12 +94,125 @@ protected function standard_preference_fields(MoodleQuickForm $mform) { ); $mform->setType('nameordering', PARAM_ALPHA); + $mform->addElement( + 'advcheckbox', + 'attachments', + get_string('attachments', 'quiz_essaydownload'), + get_string('includeattachments', 'quiz_essaydownload') + ); + $mform->addHelpButton('attachments', 'includeattachments', 'quiz_essaydownload'); + + $mform->addElement( + 'advcheckbox', + 'questiontext', + get_string('questiontext', 'question'), + get_string('includequestiontext', 'quiz_essaydownload') + ); + $mform->addHelpButton('questiontext', 'includequestiontext', 'quiz_essaydownload'); + + $mform->addElement('select', 'fileformat', get_string('fileformat', 'quiz_essaydownload'), [ + 'txt' => get_string('fileformattxt', 'quiz_essaydownload'), + 'pdf' => get_string('fileformatpdf', 'quiz_essaydownload'), + ]); + $mform->setType('fileformat', PARAM_ALPHA); + $mform->setDefault('fileformat', 'pdf'); + $mform->addHelpButton('fileformat', 'fileformat', 'quiz_essaydownload'); + + $mform->addElement('select', 'source', get_string('source', 'quiz_essaydownload'), [ + 'plain' => get_string('sourcesummary', 'quiz_essaydownload'), + 'html' => get_string('sourceoriginal', 'quiz_essaydownload'), + ]); + $mform->disabledIf('source', 'fileformat', 'neq', 'pdf'); + $mform->setType('source', PARAM_ALPHA); + $mform->setDefault('source', 'html'); + $mform->addHelpButton('source', 'source', 'quiz_essaydownload'); + $mform->addElement( 'advcheckbox', 'shortennames', - get_string('additionalsettings', 'quiz_essaydownload'), + get_string('compatibility', 'quiz_essaydownload'), get_string('shortennames', 'quiz_essaydownload') ); $mform->addHelpButton('shortennames', 'shortennames', 'quiz_essaydownload'); } + + /** + * Fields to configure the PDF layout. + * + * @param MoodleQuickForm $mform the form + * @return void + */ + protected function pdf_layout_fields(MoodleQuickForm $mform) { + $mform->addElement('select', 'page', get_string('page', 'quiz_essaydownload'), [ + 'a4' => get_string('pagea4', 'quiz_essaydownload'), + 'letter' => get_string('pageletter', 'quiz_essaydownload'), + ]); + $mform->setType('page', PARAM_ALPHA); + $mform->setDefault('page', 'a4'); + $mform->disabledIf('page', 'fileformat', 'neq', 'pdf'); + + $margingroup = []; + $margingroup[] = $mform->createElement('text', 'marginleft', '', ['size' => 3]); + $mform->setType('marginleft', PARAM_INT); + $margingroup[] = $mform->createElement('text', 'marginright', '', ['size' => 3]); + $mform->setType('marginright', PARAM_INT); + $margingroup[] = $mform->createElement('text', 'margintop', '', ['size' => 3]); + $mform->setType('margintop', PARAM_INT); + $margingroup[] = $mform->createElement('text', 'marginbottom', '', ['size' => 3]); + $mform->setType('marginbottom', PARAM_INT); + $mform->addGroup($margingroup, 'margingroup', get_string('margins', 'quiz_essaydownload'), ' ', false); + $mform->disabledIf('margingroup', 'fileformat', 'neq', 'pdf'); + + $mform->addElement('select', 'linespacing', get_string('linespacing', 'quiz_essaydownload'), [ + '1' => get_string('linesingle', 'quiz_essaydownload'), + '1.5' => get_string('lineoneandhalf', 'quiz_essaydownload'), + '2' => get_string('linedouble', 'quiz_essaydownload'), + ]); + $mform->setType('linespacing', PARAM_FLOAT); + $mform->disabledIf('linespacing', 'fileformat', 'neq', 'pdf'); + + $mform->addElement('select', 'font', get_string('font', 'quiz_essaydownload'), [ + 'sans' => get_string('fontsans', 'quiz_essaydownload'), + 'serif' => get_string('fontserif', 'quiz_essaydownload'), + 'mono' => get_string('fontmono', 'quiz_essaydownload'), + ]); + $mform->setType('font', PARAM_ALPHA); + $mform->setDefault('font', 'serif'); + $mform->disabledIf('font', 'fileformat', 'neq', 'pdf'); + $mform->addHelpButton('font', 'font', 'quiz_essaydownload'); + + $mform->addElement('text', 'fontsize', get_string('fontsize', 'quiz_essaydownload'), ['size' => 3]); + $mform->setType('fontsize', PARAM_INT); + $mform->disabledIf('fontsize', 'fileformat', 'neq', 'pdf'); + $mform->addHelpButton('fontsize', 'fontsize', 'quiz_essaydownload'); + } + + /** + * Validation of our settings form, e. g. font size or page margins. + * + * @param array $data submitted data in form ['fieldname' => value] + * @param array $files array of uploaded files ['element_name' => tmp_file_path] + * @return array errors in form ['element_name' => 'error message'] or [] if no errors + */ + public function validation($data, $files) { + $errors = parent::validation($data, $files); + + // No further validation to be done if using plain text format. + if ($data['fileformat'] === 'txt') { + return $errors; + } + + $margins = [$data['marginleft'], $data['marginright'], $data['margintop'], $data['marginbottom']]; + foreach ($margins as $margin) { + if ($margin > 80 || $margin < 0) { + $errors['margingroup'] = get_string('errormargin', 'quiz_essaydownload'); + } + } + + if ($data['fontsize'] > 50 || $data['fontsize'] < 6) { + $errors['fontsize'] = get_string('errorfontsize', 'quiz_essaydownload'); + } + + return $errors; + } } diff --git a/essaydownload_options.php b/essaydownload_options.php index 75d1d21..f37f485 100644 --- a/essaydownload_options.php +++ b/essaydownload_options.php @@ -23,8 +23,6 @@ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -use mod_quiz\local\reports\attempts_report_options; - defined('MOODLE_INTERNAL') || die(); // This work-around is required until Moodle 4.2 is the lowest version we support. @@ -45,24 +43,51 @@ class_alias('\mod_quiz_attempts_report_options', '\quiz_essaydownload_options_pa */ class quiz_essaydownload_options extends quiz_essaydownload_options_parent_class_alias { - /** @var bool whether to include the text response files in the archive */ - public $responsetext = true; - - /** @var bool whether to include the question text in the archive */ - public $questiontext = true; - /** @var bool whether to include attachments (if there are) in the archive */ public $attachments = true; - /** @var string whether to shorten file and path names to workaround a Windows issue */ - public $shortennames = false; + /** @var string file format TXT or PDF */ + public $fileformat = 'pdf'; + + /** @var string base font family for PDF export */ + public $font = 'sansserif'; + + /** @var int font size for PDF export */ + public $fontsize = 12; /** @var string how to organise the sub folders in the archive (by question or by attempt) */ public $groupby = 'byattempt'; + /** @var float line spacing for PDF export */ + public $linespacing = 1; + + /** @var int bottom margin for PDF export */ + public $marginbottom = 20; + + /** @var int left margin for PDF export */ + public $marginleft = 20; + + /** @var int right margin for PDF export */ + public $marginright = 20; + + /** @var int top margin for PDF export */ + public $margintop = 20; + /** @var string whether to have the last name or the first name first */ public $nameordering = 'lastfirst'; + /** @var string page format for PDF export */ + public $pageformat = 'a4'; + + /** @var bool whether to include the question text in the archive */ + public $questiontext = true; + + /** @var bool whether to shorten file and path names to workaround a Windows issue */ + public $shortennames = false; + + /** @var string which source to use: plain-text summary or original HTML text */ + public $source = 'html'; + /** * Constructor * @@ -84,12 +109,21 @@ public function __construct($mode, $quiz, $cm, $course) { public function get_initial_form_data() { $toform = new stdClass(); - $toform->responsetext = $this->responsetext; - $toform->questiontext = $this->questiontext; $toform->attachments = $this->attachments; - $toform->shortennames = $this->shortennames; + $toform->fileformat = $this->fileformat; + $toform->font = $this->font; + $toform->fontsize = $this->fontsize; $toform->groupby = $this->groupby; + $toform->linespacing = $this->linespacing; + $toform->marginbottom = $this->marginbottom; + $toform->marginleft = $this->marginleft; + $toform->marginright = $this->marginright; + $toform->margintop = $this->margintop; $toform->nameordering = $this->nameordering; + $toform->pageformat = $this->pageformat; + $toform->questiontext = $this->questiontext; + $toform->shortennames = $this->shortennames; + $toform->source = $this->source; return $toform; } @@ -100,24 +134,42 @@ public function get_initial_form_data() { * @param object $fromform data from the settings form */ public function setup_from_form_data($fromform): void { - $this->responsetext = $fromform->responsetext; - $this->questiontext = $fromform->questiontext; $this->attachments = $fromform->attachments; - $this->shortennames = $fromform->shortennames; + $this->fileformat = $fromform->fileformat; + $this->font = $fromform->font ?? ''; + $this->fontsize = $fromform->fontsize ?? ''; $this->groupby = $fromform->groupby; + $this->linespacing = $fromform->linespacing ?? ''; + $this->marginbottom = $fromform->marginbottom ?? ''; + $this->marginleft = $fromform->marginleft ?? ''; + $this->marginright = $fromform->marginright ?? ''; + $this->margintop = $fromform->margintop ?? ''; $this->nameordering = $fromform->nameordering; + $this->pageformat = $fromform->pageformat ?? ''; + $this->questiontext = $fromform->questiontext; + $this->shortennames = $fromform->shortennames; + $this->source = $fromform->source ?? ''; } /** * Set the fields of this object from the URL parameters. */ public function setup_from_params() { - $this->responsetext = optional_param('responsetext', $this->responsetext, PARAM_BOOL); - $this->questiontext = optional_param('questiontext', $this->questiontext, PARAM_BOOL); $this->attachments = optional_param('attachments', $this->attachments, PARAM_BOOL); - $this->shortennames = optional_param('shortennames', $this->shortennames, PARAM_BOOL); + $this->fileformat = optional_param('fileformat', $this->fileformat, PARAM_ALPHA); + $this->font = optional_param('font', $this->font, PARAM_ALPHA); + $this->fontsize = optional_param('fontsize', $this->fontsize, PARAM_INT); $this->groupby = optional_param('groupby', $this->groupby, PARAM_ALPHA); + $this->linespacing = optional_param('linespacing', $this->linespacing, PARAM_FLOAT); + $this->marginbottom = optional_param('marginbottom', $this->marginbottom, PARAM_INT); + $this->marginleft = optional_param('marginleft', $this->marginleft, PARAM_INT); + $this->marginright = optional_param('marginright', $this->marginright, PARAM_INT); + $this->margintop = optional_param('margintop', $this->margintop, PARAM_INT); $this->nameordering = optional_param('nameordering', $this->nameordering, PARAM_ALPHA); + $this->pageformat = optional_param('pageformat', $this->pageformat, PARAM_ALPHA); + $this->questiontext = optional_param('questiontext', $this->questiontext, PARAM_BOOL); + $this->shortennames = optional_param('shortennames', $this->shortennames, PARAM_BOOL); + $this->source = optional_param('source', $this->source, PARAM_ALPHA); } /** @@ -135,8 +187,11 @@ public function update_user_preferences() { } /** - * Override parent method, because our settings cannot be incompatible. + * Deal with conflicting options, e.g. user requesting TXT output, but HTML source. */ public function resolve_dependencies() { + if ($this->fileformat === 'txt') { + $this->source = 'plain'; + } } } diff --git a/lang/en/quiz_essaydownload.php b/lang/en/quiz_essaydownload.php index a21ac1c..b220210 100644 --- a/lang/en/quiz_essaydownload.php +++ b/lang/en/quiz_essaydownload.php @@ -23,26 +23,55 @@ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -$string['additionalsettings'] = 'Additional settings'; +$string['attachments'] = 'Attachments'; $string['byattempt'] = 'Attempt'; $string['byquestion'] = 'Question'; +$string['compatibility'] = 'Compatibility setting'; $string['errorfilename'] = 'error-{$a}.txt'; +$string['errorfontsize'] = 'Font size should be an integer between 6 and 50.'; +$string['errormargin'] = 'All page margins must be integers between 0 and 80.'; $string['errormessage'] = 'An internal error occurred. The archive is probably incomplete. Please contact the developers of the Essay responses downloader plugin (quiz_essaydownload) and send them the details below:'; $string['essaydownload'] = 'Download essay responses'; +$string['fileformat'] = 'File format'; +$string['fileformat_help'] = 'You can choose between two formats:'; +$string['fileformatpdf'] = 'Portable Document Format (PDF)'; +$string['fileformattxt'] = 'Plain-text (TXT)'; $string['firstlast'] = 'First name - Last name'; +$string['font'] = 'Font'; +$string['font_help'] = 'Note that when using the original HTML formatted text, the actual font may still be different, according to the formatting.

When using the plain-text summary, you might want to use a monospaced font.'; +$string['fontmono'] = 'Monospaced'; +$string['fontsans'] = 'Sans-serif'; +$string['fontserif'] = 'Serif'; +$string['fontsize'] = 'Font size (points)'; +$string['fontsize_help'] = 'Note that when using the original HTML formatted text, the actual font size may still be different, according to the formatting'; +$string['generaloptions'] = 'General options'; $string['groupby'] = 'Group by'; $string['groupby_help'] = 'The archive can be structured by question or by attempt:'; -$string['includeattachments'] = 'Include attachments, if there are any'; -$string['includequestiontext'] = 'Include question text'; -$string['includeresponsetext'] = 'Include response text'; +$string['includeattachments'] = 'Also download possible attachments included in a student\'s answer.'; +$string['includeattachments_help'] = 'Any attachment is provided as-is. Please note that attachments might contain malware.'; +$string['includequestiontext'] = 'Also include question text.'; +$string['includequestiontext_help'] = 'Including the question text might be useful if your quiz uses random questions.'; $string['lastfirst'] = 'Last name - First name'; +$string['linedouble'] = 'Double'; +$string['lineoneandhalf'] = '1.5 lines'; +$string['linesingle'] = 'Single'; +$string['linespacing'] = 'Line spacing'; +$string['margins'] = 'Page margins (mm): left, right, top, bottom'; $string['nameordering'] = 'Name format'; $string['noessayquestion'] = 'This quiz does not contain any essay questions.'; $string['nothingtodownload'] = 'Nothing to download'; -$string['options'] = 'Options'; +$string['page'] = 'Page format'; +$string['pagea4'] = 'A4'; +$string['pageletter'] = 'Letter'; +$string['pdfoptions'] = 'PDF settings'; $string['plugindescription'] = 'Download text answers and attachment files submitted in response to essay questions in a quiz.'; $string['pluginname'] = 'Essay responses downloader plugin (quiz_essaydownload)'; +$string['presentedto'] = 'Presented to: {$a}'; $string['privacy:metadata'] = 'The quiz essay download plugin does not store any personal data about any user.'; -$string['shortennames'] = 'Shorten archive name and subfolder names'; -$string['shortennames_help'] = 'If the total path name of an extracted file is larger than 260 characters, this may cause problems with Windows\' built-in extraction tool. In this case, activating this checkbox may help. It might, however, make it more difficult to identify your students, if they have very long names.'; -$string['whattoinclude'] = 'What to include'; +$string['response'] = 'Response'; +$string['shortennames'] = 'Shorten archive name and subfolder names.'; +$string['shortennames_help'] = 'If the total path name of an extracted file is longer than 260 characters, this may cause problems with Windows\' built-in extraction tool. In this case, activating this checkbox may help. It might, however, make it more difficult to identify your students, if they have very long names.'; +$string['source'] = 'Text source to use'; +$string['source_help'] = 'If the question text and/or the student\'s response is written in HTML format, Moodle will automatically generate a plain-text summary of the formatted text. That summary will have all HTML tags removed and some basic formatting applied (e. g. headings and bold font transformed to ALL CAPS).

When generating PDF files, you can choose whether you want to use that summary or the original question text / student answer with its formatting. If you choose the summary, you should probably use a monospaced font as well.

Note that you cannot use the formatted original text when generating TXT files. Also note that the setting will not have any effect if the student was asked to write their answer in non-HTML format, e. g. plain-text.'; +$string['sourceoriginal'] = 'Original HTML formatted text'; +$string['sourcesummary'] = 'Plain-text summary'; diff --git a/report.php b/report.php index b57053b..18ad71e 100644 --- a/report.php +++ b/report.php @@ -42,6 +42,7 @@ class_alias('\quiz_attempt', '\quiz_essaydownload_quiz_attempt_alias'); require_once($CFG->dirroot . '/mod/quiz/report/essaydownload/essaydownload_form.php'); require_once($CFG->dirroot . '/mod/quiz/report/essaydownload/essaydownload_options.php'); +require_once($CFG->libdir . '/pdflib.php'); /** * Quiz report subclass for the quiz_essaydownload report. @@ -243,6 +244,9 @@ public function get_attempts_and_names(sql_join $joins): array { $attempts = []; foreach ($results as $result) { + $attempts[$result->attemptid]['firstname'] = $result->firstname; + $attempts[$result->attemptid]['lastname'] = $result->lastname; + // If the user has requested short filenames, we limit the last and first name to 40 // characters each. if ($this->options->shortennames) { @@ -262,7 +266,7 @@ public function get_attempts_and_names(sql_join $joins): array { $path = $path . '_' . date('Ymd_His', $result->timefinish); $path = self::clean_filename($path); - $attempts[$result->attemptid] = $path; + $attempts[$result->attemptid]['path'] = $path; } return $attempts; @@ -287,20 +291,55 @@ public function get_details_for_attempt(int $attemptid): array { $slots = $attemptobj->get_slots(); foreach ($slots as $slot) { + $questiondefinition = $quba->get_question($slot, false); // If we are not dealing with an essay question, we can skip this slot. - $qtype = $quba->get_question($slot, false)->get_type_name(); + $qtype = $questiondefinition->get_type_name(); if ($qtype !== 'essay') { continue; } - $questionfolder = 'Question_' . $attemptobj->get_question_number($slot) . '_-_' . $attemptobj->get_question_name($slot); + $qprefix = ($this->options->shortennames ? 'Q_' : 'Question_'); + $questionfolder = $qprefix . $attemptobj->get_question_number($slot) . '_-_' . $attemptobj->get_question_name($slot); $questionfolder = self::clean_filename($questionfolder); $details[$questionfolder] = []; + + // First, fetch summary for question text and response, because we can easily retrieve it now and use it + // as a fallback. $details[$questionfolder]['questiontext'] = $quba->get_question_summary($slot) ?? ''; $details[$questionfolder]['responsetext'] = $quba->get_response_summary($slot) ?? ''; + // If the user wants to use formatted text rather than the summary, fetch the true question text + // and response now. Note that this setting will be overridden, if output is TXT instead of PDF. + // We use format_text(), because either we currently have the summary (plain-text) or we have + // formatted text, but it might be in MARKDOWN or other formats. We consider the text as trusted + // (because it has been filtered before) and disable filtering. Also, we do not put
tags + // around it, as that is done anyway during generation of the PDF. $qa = $quba->get_question_attempt($slot); + if ($this->options->source === 'html') { + $formattingoptions = [ + 'trusted' => true, + 'filter' => false, + 'para' => false, + ]; + + $responsehtml = format_text( + strval($qa->get_last_qt_var('answer', '')), + $qa->get_last_qt_var('answerformat', FORMAT_PLAIN), + $formattingoptions + ); + + $questionhtml = format_text( + $questiondefinition->questiontext, + $questiondefinition->questiontextformat, + $formattingoptions + ); + + $details[$questionfolder]['responsetext'] = $responsehtml; + $details[$questionfolder]['questiontext'] = $questionhtml; + } + + // Finally, fetch attachments, if there are. $details[$questionfolder]['attachments'] = $qa->get_last_qt_files('attachments', $quba->get_owning_context()->id); } return $details; @@ -335,30 +374,56 @@ protected function process_and_download(): void { $errors = 0; // Iterate over every attempt and every question. - foreach ($this->attempts as $attemptid => $attemptpath) { + foreach ($this->attempts as $attemptid => $attemptdata) { $questions = $this->get_details_for_attempt($attemptid); foreach ($questions as $questionpath => $questiondetails) { // Depending on the user's choice, the files will either be grouped by attempt or by question. if ($this->options->groupby === 'byattempt') { - $path = $attemptpath . '/' . $questionpath; + $path = $attemptdata['path'] . '/' . $questionpath; + } else { + $path = $questionpath . '/' . $attemptdata['path']; + } + + // Build the full name according to user setting. + if ($this->options->nameordering === 'firstlast') { + $fullname = $attemptdata['firstname'] . ' ' . $attemptdata['lastname']; } else { - $path = $questionpath . '/' . $attemptpath; + $fullname = $attemptdata['lastname'] . ' ' . $attemptdata['firstname']; } try { - if ($this->options->questiontext) { - $zipwriter->add_file_from_string($path . '/' . 'questiontext.txt', $questiondetails['questiontext']); - $emptyarchive = false; + if ($this->options->fileformat === 'pdf') { + $zipwriter->add_file_from_string( + $path . '/' . 'response.pdf', + + $this->generate_pdf( + $questiondetails['responsetext'], + get_string('response', 'quiz_essaydownload'), + $fullname, + $fullname + ) + ); + } else { + $zipwriter->add_file_from_string($path . '/' . 'response.txt', $questiondetails['responsetext']); } - if ($this->options->responsetext) { - $zipwriter->add_file_from_string($path . '/' . 'response.txt', $questiondetails['responsetext']); - $emptyarchive = false; + $emptyarchive = false; + + // Only include question text if instructed to do so. + if ($this->options->questiontext) { + if ($this->options->fileformat === 'pdf') { + $zipwriter->add_file_from_string($path . '/' . 'questiontext.pdf', $this->generate_pdf( + $questiondetails['questiontext'], + get_string('questiontext', 'question'), + get_string('presentedto', 'quiz_essaydownload', $fullname) + )); + } else { + $zipwriter->add_file_from_string($path . '/' . 'questiontext.txt', $questiondetails['questiontext']); + } } - if (!empty($questiondetails['attachments']) && $this->options->attachments) { - $emptyarchive = false; + if ($this->options->attachments && !empty($questiondetails['attachments'])) { foreach ($questiondetails['attachments'] as $file) { $zipwriter->add_file_from_stored_file($path . '/attachments/' . $file->get_filename(), $file); } @@ -392,7 +457,7 @@ protected function process_and_download(): void { * @param string $type the notification type, e. g. 'error' or 'info' or 'warn' * @return void */ - protected function notification(string $message, string $type = 'error') { + protected function notification(string $message, string $type = 'error'): void { global $OUTPUT; // Printing the standard header. We'll set $hasquestions and $hasstudents to true here, @@ -421,4 +486,59 @@ protected static function clean_filename(string $filename): string { return clean_filename(str_replace(' ', '_', $filename)); } + /** + * Generate a PDF file from a given HTML code. + * + * @param string $text HTML code to be typeset + * @param string $header upper line of the header, printed in bold face + * @param string $subheader lower line of the header + * @param string $author author name to be stored in the document information field + * @return string PDF code + */ + protected function generate_pdf(string $text, string $header = '', string $subheader = '', string $author = ''): string { + // The text might contain \xC2\xA0 for a unicode NON-BREAK SPACE character. This can confuse TCPDF, so we + // rather remove it here. + $text = str_replace("\xc2\xa0", " ", $text); + + $doc = new pdf('P', 'mm', $this->options->pageformat); + + $doc->SetCreator('quiz_essaydownload plugin for Moodle LMS'); + $doc->SetAuthor($author); + $doc->SetTitle(''); + $doc->SetKeywords(''); + $doc->SetSubject(''); + + // The configured top margin is used for the distance between the page's top border and the start of the header. + $doc->setHeaderMargin($this->options->margintop); + + // In order for the document's text to be reasonably separated from the header (and its rule), we add some space + // relative to linespacing and font size. + $doc->SetMargins( + $this->options->marginleft, + $this->options->margintop + $this->options->linespacing * $this->options->fontsize, + $this->options->marginright + ); + $doc->setPrintFooter(false); + + if ($this->options->font === 'serif') { + $fontname = 'freeserif'; + } else if ($this->options->font === 'mono') { + $fontname = 'freemono'; + } else { + $fontname = 'freesans'; + } + $doc->SetFont($fontname, '', $this->options->fontsize); + $doc->setHeaderFont([$fontname, '', $this->options->fontsize]); + + $doc->setHeaderData('', 0, $header, $subheader); + $doc->setFooterData(); + $doc->SetAutoPageBreak(true, $this->options->marginbottom); + + $doc->AddPage(); + $linespacebase = 1.25; + $doc->writeHTML('
' . $text . '
'); + + return $doc->Output('', 'S'); + } + } diff --git a/tests/behat/form.feature b/tests/behat/form.feature new file mode 100644 index 0000000..2310b3e --- /dev/null +++ b/tests/behat/form.feature @@ -0,0 +1,63 @@ +@quiz @quiz_essaydownload @javascript +Feature: Validation and display of the form + + Background: + Given the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "users" exist: + | username | firstname | lastname | + | teacher1 | T1 | Teacher1 | + | student1 | S1 | Student1 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "question categories" exist: + | contextlevel | reference | name | + | Course | C1 | Test questions | + And the following "activities" exist: + | activity | name | intro | course | groupmode | + | quiz | Quiz 1 | Quiz 1 description | C1 | 1 | + And the following "questions" exist: + | questioncategory | qtype | name | questiontext | + | Test questions | essay | Q1 | First question | + And quiz "Quiz 1" contains the following questions: + | question | page | maxmark | + | Q1 | 1 | 1.0 | + And user "student1" has attempted "Quiz 1" with responses: + | slot | response | + | 1 | The first student's answer. | + + Scenario: Invalid form values should trigger an error message + When I am on the "Quiz 1" "quiz_essaydownload > essaydownload report" page logged in as "teacher1" + And I set the field "marginleft" to "100" + And I press "Download" + And I wait until the page is ready + Then I should see "All page margins must be integers between 0 and 80." + When I set the following fields to these values: + | marginleft | 20 | + | marginright | -1 | + And I press "Download" + And I wait until the page is ready + Then I should see "All page margins must be integers between 0 and 80." + When I set the following fields to these values: + | marginleft | 20 | + | marginright | 20 | + | fontsize | 5 | + And I press "Download" + And I wait until the page is ready + Then I should see "Font size should be an integer between 6 and 50." + + Scenario: PDF specific fields should be disabled if output set to TXT + When I am on the "Quiz 1" "quiz_essaydownload > essaydownload report" page logged in as "teacher1" + When I set the field "fileformat" to "txt" + Then the "source" "select" should be disabled + And the "page" "select" should be disabled + And the "marginleft" "field" should be disabled + And the "marginright" "field" should be disabled + And the "margintop" "field" should be disabled + And the "marginbottom" "field" should be disabled + And the "linespacing" "select" should be disabled + And the "font" "select" should be disabled + And the "fontsize" "field" should be disabled diff --git a/tests/report_test.php b/tests/report_test.php index 62558b6..71ebc97 100644 --- a/tests/report_test.php +++ b/tests/report_test.php @@ -127,7 +127,7 @@ public function test_long_names_being_shortened(): void { $student = \phpunit_util::get_data_generator()->create_user( [ 'firstname' => 'ExtremelyLongFirstNameForThisVerySpecificPerson', - 'lastname' => 'OneThingIsSureThisLastNameIsNotGoingToEndVerySoon' + 'lastname' => 'OneThingIsSureThisLastNameIsNotGoingToEndVerySoon', ] ); \phpunit_util::get_data_generator()->enrol_user($student->id, $course->id, 'student'); @@ -154,12 +154,14 @@ public function test_long_names_being_shortened(): void { self::assertCount(1, $fetchedattempts); $i = 0; - foreach ($fetchedattempts as $fetchedid => $fetchedname) { + foreach ($fetchedattempts as $fetchedid => $fetcheddata) { // The attempt is stored in a somewhat obscure way. $attemptobj = $attempt[2]->get_attempt(); $id = $attemptobj->id; self::assertEquals($id, $fetchedid); + self::assertEquals($student->firstname, $fetcheddata['firstname']); + self::assertEquals($student->lastname, $fetcheddata['lastname']); $firstname = clean_filename(str_replace(' ', '_', substr($student->firstname, 0, 40))); $lastname = clean_filename(str_replace(' ', '_', substr($student->lastname, 0, 40))); @@ -169,9 +171,14 @@ public function test_long_names_being_shortened(): void { // We will not compare the minutes and seconds, because there might be a small difference and // we don't really care. If the timestamp is correct up to the hours, we can safely assume the // conversion worked. - self::assertStringStartsWith(substr($name, 0, -4), $fetchedname); + self::assertStringStartsWith(substr($name, 0, -4), $fetcheddata['path']); $i++; } + + // Fetch details for first attempt and test whether the prefix ist Q_1 instead of Question_1. + $details = $report->get_details_for_attempt(array_keys($fetchedattempts)[0]); + self::assertCount(1, $details); + self::assertStringStartsWith('Q_1_-_', array_keys($details)[0]); } public function test_custom_name_order(): void { @@ -211,12 +218,14 @@ public function test_custom_name_order(): void { self::assertCount(1, $fetchedattempts); $i = 0; - foreach ($fetchedattempts as $fetchedid => $fetchedname) { + foreach ($fetchedattempts as $fetchedid => $fetcheddata) { // The attempt is stored in a somewhat obscure way. $attemptobj = $attempt[2]->get_attempt(); $id = $attemptobj->id; self::assertEquals($id, $fetchedid); + self::assertEquals($student->firstname, $fetcheddata['firstname']); + self::assertEquals($student->lastname, $fetcheddata['lastname']); $firstname = clean_filename(str_replace(' ', '_', $student->firstname)); $lastname = clean_filename(str_replace(' ', '_', $student->lastname)); @@ -226,7 +235,7 @@ public function test_custom_name_order(): void { // We will not compare the minutes and seconds, because there might be a small difference and // we don't really care. If the timestamp is correct up to the hours, we can safely assume the // conversion worked. - self::assertStringStartsWith(substr($name, 0, -4), $fetchedname); + self::assertStringStartsWith(substr($name, 0, -4), $fetcheddata['path']); $i++; } } @@ -259,12 +268,14 @@ public function test_get_attempts_and_names_without_groups(): void { self::assertCount(count($students), $fetchedattempts); $i = 0; - foreach ($fetchedattempts as $fetchedid => $fetchedname) { + foreach ($fetchedattempts as $fetchedid => $fetcheddata) { // The attempt is stored in a somewhat obscure way. $attemptobj = $attempts[$i][2]->get_attempt(); $id = $attemptobj->id; self::assertEquals($id, $fetchedid); + self::assertEquals($students[$i]->firstname, $fetcheddata['firstname']); + self::assertEquals($students[$i]->lastname, $fetcheddata['lastname']); $firstname = clean_filename(str_replace(' ', '_', $students[$i]->firstname)); $lastname = clean_filename(str_replace(' ', '_', $students[$i]->lastname)); @@ -274,7 +285,7 @@ public function test_get_attempts_and_names_without_groups(): void { // We will not compare the minutes and seconds, because there might be a small difference and // we don't really care. If the timestamp is correct up to the hours, we can safely assume the // conversion worked. - self::assertStringStartsWith(substr($name, 0, -4), $fetchedname); + self::assertStringStartsWith(substr($name, 0, -4), $fetcheddata['path']); $i++; } } @@ -327,6 +338,8 @@ public function test_get_attempts_and_names_with_separated_groups(): void { self::assertEquals($id, array_keys($fetchedattempts)[0]); // Comparing to the second student. + self::assertEquals($students[1]->firstname, $fetchedattempts[$id]['firstname']); + self::assertEquals($students[1]->lastname, $fetchedattempts[$id]['lastname']); $firstname = clean_filename(str_replace(' ', '_', $students[1]->firstname)); $lastname = clean_filename(str_replace(' ', '_', $students[1]->lastname)); $name = $lastname . '_' . $firstname . '_' . $id . '_' . date('Ymd_His', $attemptobj->timefinish); @@ -334,7 +347,7 @@ public function test_get_attempts_and_names_with_separated_groups(): void { // We will not compare the minutes and seconds, because there might be a small difference and // we don't really care. If the timestamp is correct up to the hours, we can safely assume the // conversion worked. - self::assertStringStartsWith(substr($name, 0, -4), reset($fetchedattempts)); + self::assertStringStartsWith(substr($name, 0, -4), $fetchedattempts[$id]['path']); // Now, add one more student to group 1 and refetch. We'll just check the count. $generator->create_group_member(['groupid' => $group1->id, 'userid' => $students[0]->id]); @@ -396,7 +409,7 @@ public function test_get_details_for_attempt_with_single_essay_question(): void $quiz = $this->create_test_quiz($course); quiz_essaydownload_test_helper::add_essay_question($questiongenerator, $quiz, [ 'name' => 'My Question Title / Test', - 'questiontext' => ['text' => '

Go write your stuff!

', 'format' => FORMAT_HTML], + 'questiontext' => ['text' => 'Go write your stuff!', 'format' => FORMAT_PLAIN], ]); // Add a student and start an attempt. @@ -406,7 +419,7 @@ public function test_get_details_for_attempt_with_single_essay_question(): void // Submit a response and finish the attempt. $timenow = time(); - $tosubmit = [1 => ['answer' => '

Here we go.

', 'answerformat' => FORMAT_HTML]]; + $tosubmit = [1 => ['answer' => 'Here we go.', 'answerformat' => FORMAT_PLAIN]]; $attemptobj->process_submitted_actions($timenow, false, $tosubmit); $attemptobj->process_finish($timenow, false); @@ -453,7 +466,7 @@ public function test_get_details_for_attempt_with_two_essay_questions(): void { foreach ($questionsandanswers as $data) { quiz_essaydownload_test_helper::add_essay_question($questiongenerator, $quiz, [ 'name' => $data['name'], - 'questiontext' => ['text' => "{$data['text']}", 'format' => FORMAT_HTML], + 'questiontext' => ['text' => $data['text'], 'format' => FORMAT_PLAIN], ]); } @@ -465,8 +478,8 @@ public function test_get_details_for_attempt_with_two_essay_questions(): void { // Submit a response and finish the attempt. $timenow = time(); $tosubmit = [ - 1 => ['answer' => "

{$questionsandanswers[1]['response']}

", 'answerformat' => FORMAT_HTML], - 2 => ['answer' => "

{$questionsandanswers[2]['response']}

", 'answerformat' => FORMAT_HTML], + 1 => ['answer' => $questionsandanswers[1]['response'], 'answerformat' => FORMAT_PLAIN], + 2 => ['answer' => $questionsandanswers[2]['response'], 'answerformat' => FORMAT_PLAIN], ]; $attemptobj->process_submitted_actions($timenow, false, $tosubmit); $attemptobj->process_finish($timenow, false); @@ -511,7 +524,7 @@ public function test_get_details_for_attempt_with_one_essay_and_two_other_questi $this->add_two_regular_questions($questiongenerator, $quiz); quiz_essaydownload_test_helper::add_essay_question($questiongenerator, $quiz, [ 'name' => 'My Question Title / Test', - 'questiontext' => ['text' => '

Go write your stuff!

', 'format' => FORMAT_HTML], + 'questiontext' => ['text' => 'Go write your stuff!', 'format' => FORMAT_PLAIN], ]); // Add a student and start an attempt. @@ -525,7 +538,7 @@ public function test_get_details_for_attempt_with_one_essay_and_two_other_questi $tosubmit = [ 1 => ['answer' => 'frog'], 2 => ['answer' => '3.14'], - 3 => ['answer' => '

Here we go.

', 'answerformat' => FORMAT_HTML], + 3 => ['answer' => 'Here we go.', 'answerformat' => FORMAT_PLAIN], ]; $attemptobj->process_submitted_actions($timenow, false, $tosubmit); $attemptobj->process_finish($timenow, false); @@ -619,7 +632,7 @@ public function test_get_details_for_attempt_with_random_essay_question(): void // resolve to a shortanswer question. $timenow = time(); $tosubmit = [ - 1 => ['answer' => '

Foo Bar Quak.

', 'answerformat' => FORMAT_HTML], + 1 => ['answer' => 'Foo Bar Quak.', 'answerformat' => FORMAT_PLAIN], ]; $attemptobj->process_submitted_actions($timenow, false, $tosubmit); $attemptobj->process_finish($timenow, false); @@ -657,7 +670,7 @@ public function test_get_details_for_attempt_with_text_and_attachment(): void { $quiz = $this->create_test_quiz($course); quiz_essaydownload_test_helper::add_essay_question($questiongenerator, $quiz, [ 'name' => 'My Question Title / Test', - 'questiontext' => ['text' => '

Go write your stuff!

', 'format' => FORMAT_HTML], + 'questiontext' => ['text' => 'Go write your stuff!', 'format' => FORMAT_PLAIN], 'responseformat' => 'editorfilepicker', 'attachments' => 2, ]); @@ -675,8 +688,8 @@ public function test_get_details_for_attempt_with_text_and_attachment(): void { quiz_essaydownload_test_helper::save_file_to_draft_area($usercontextid, $attachementsdraftid, 'greeting.txt', 'Foobar'); $timenow = time(); $tosubmit = [1 => [ - 'answer' => '

Foo.

', - 'answerformat' => FORMAT_HTML, + 'answer' => 'Foo.', + 'answerformat' => FORMAT_PLAIN, 'answer:itemid' => 1, 'attachments' => $attachementsdraftid, ]]; @@ -694,8 +707,8 @@ public function test_get_details_for_attempt_with_text_and_attachment(): void { // Submit a response and finish the attempt. $timenow = time(); $tosubmit = [1 => [ - 'answer' => '

Here we go.

', - 'answerformat' => FORMAT_HTML, + 'answer' => 'Here we go.', + 'answerformat' => FORMAT_PLAIN, 'answer:itemid' => 1, 'attachments' => $attachementsdraftid, ]]; @@ -707,6 +720,14 @@ public function test_get_details_for_attempt_with_text_and_attachment(): void { list($currentgroup, $allstudentjoins, $groupstudentjoins, $allowedjoins) = $report->init('essaydownload', 'quiz_essaydownload_form', $quiz, $cm, $course); + // Use reflection to force text source to plain (i. e. summary). + $reflectedreport = new \ReflectionClass($report); + $reflectedoptions = $reflectedreport->getProperty('options'); + $reflectedoptions->setAccessible(true); + $options = new quiz_essaydownload_options('essaydownload', $quiz, $cm, $course); + $options->source = 'plain'; + $reflectedoptions->setValue($report, $options); + // Fetch the attemp using the report's API. $fetchedattempts = $report->get_attempts_and_names($groupstudentjoins); self::assertCount(1, $fetchedattempts); @@ -746,7 +767,7 @@ public function test_get_details_for_attempt_with_unanswered_question(): void { $quiz = $this->create_test_quiz($course); quiz_essaydownload_test_helper::add_essay_question($questiongenerator, $quiz, [ 'name' => 'My Question Title / Test', - 'questiontext' => ['text' => '

Go write your stuff!

', 'format' => FORMAT_HTML], + 'questiontext' => ['text' => 'Go write your stuff!', 'format' => FORMAT_PLAIN], ]); // Add a student and start an attempt. @@ -756,7 +777,7 @@ public function test_get_details_for_attempt_with_unanswered_question(): void { // Finish the attempt without submitting an answer. $timenow = time(); - $tosubmit = [1 => ['answer' => '', 'answerformat' => FORMAT_HTML]]; + $tosubmit = [1 => ['answer' => '', 'answerformat' => FORMAT_PLAIN]]; $attemptobj->process_submitted_actions($timenow, false, $tosubmit); $attemptobj->process_finish($timenow, false); @@ -786,4 +807,214 @@ public function test_get_details_for_attempt_with_unanswered_question(): void { self::assertEmpty($detail['attachments']); } } + + public function test_pdf_from_summary_when_input_is_html(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + // Create a course and a quiz with an essay question. + $generator = $this->getDataGenerator(); + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $course = $generator->create_course(); + $quiz = $this->create_test_quiz($course); + quiz_essaydownload_test_helper::add_essay_question($questiongenerator, $quiz, [ + 'name' => 'My Question Title / Test', + 'questiontext' => ['text' => '

Go write your stuff!

', 'format' => FORMAT_HTML], + ]); + + // Add a student and start an attempt. + $student = $generator->create_user(); + $generator->enrol_user($student->id, $course->id, 'student'); + list($quizobj, $quba, $attemptobj) = quiz_essaydownload_test_helper::start_attempt_at_quiz($quiz, $student); + + // Submit a response and finish the attempt. + $timenow = time(); + $tosubmit = [1 => ['answer' => '

Here we go.

', 'answerformat' => FORMAT_HTML]]; + $attemptobj->process_submitted_actions($timenow, false, $tosubmit); + $attemptobj->process_finish($timenow, false); + + $cm = get_coursemodule_from_id('quiz', $quiz->cmid); + $report = new quiz_essaydownload_report(); + list($currentgroup, $allstudentjoins, $groupstudentjoins, $allowedjoins) = + $report->init('essaydownload', 'quiz_essaydownload_form', $quiz, $cm, $course); + + // Use reflection to force options. + $reflectedreport = new \ReflectionClass($report); + $reflectedoptions = $reflectedreport->getProperty('options'); + $reflectedoptions->setAccessible(true); + $options = new quiz_essaydownload_options('essaydownload', $quiz, $cm, $course); + $options->source = 'plain'; + $reflectedoptions->setValue($report, $options); + + // Fetch the attemp using the report's API. + $fetchedattempts = $report->get_attempts_and_names($groupstudentjoins); + self::assertCount(1, $fetchedattempts); + + // Fetch the details. + $details = $report->get_details_for_attempt(array_keys($fetchedattempts)[0]); + + // We expect the result to be an array with one element. The data should match the + // second response. + self::assertCount(1, $details); + foreach ($details as $label => $detail) { + self::assertEquals('Question_1_-_My_Question_Title__Test', $label); + self::assertEquals('Go write YOUR stuff!', trim($detail['questiontext'])); + self::assertStringStartsWith('Here WE go.', $detail['responsetext']); + self::assertCount(0, $detail['attachments']); + } + } + + public function test_pdf_from_html_when_input_is_html(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + // Create a course and a quiz with an essay question. + $generator = $this->getDataGenerator(); + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $course = $generator->create_course(); + $quiz = $this->create_test_quiz($course); + quiz_essaydownload_test_helper::add_essay_question($questiongenerator, $quiz, [ + 'name' => 'My Question Title / Test', + 'questiontext' => ['text' => '

Go write your stuff!

', 'format' => FORMAT_HTML], + ]); + + // Add a student and start an attempt. + $student = $generator->create_user(); + $generator->enrol_user($student->id, $course->id, 'student'); + list($quizobj, $quba, $attemptobj) = quiz_essaydownload_test_helper::start_attempt_at_quiz($quiz, $student); + + // Submit a response and finish the attempt. + $timenow = time(); + $tosubmit = [1 => ['answer' => '

Here we go.

', 'answerformat' => FORMAT_HTML]]; + $attemptobj->process_submitted_actions($timenow, false, $tosubmit); + $attemptobj->process_finish($timenow, false); + + $cm = get_coursemodule_from_id('quiz', $quiz->cmid); + $report = new quiz_essaydownload_report(); + list($currentgroup, $allstudentjoins, $groupstudentjoins, $allowedjoins) = + $report->init('essaydownload', 'quiz_essaydownload_form', $quiz, $cm, $course); + + // Fetch the attemp using the report's API. + $fetchedattempts = $report->get_attempts_and_names($groupstudentjoins); + self::assertCount(1, $fetchedattempts); + + // Fetch the details. + $details = $report->get_details_for_attempt(array_keys($fetchedattempts)[0]); + + // We expect the result to be an array with one element. The data should match the + // second response. + self::assertCount(1, $details); + foreach ($details as $label => $detail) { + self::assertEquals('Question_1_-_My_Question_Title__Test', $label); + self::assertEquals('

Go write your stuff!

', trim($detail['questiontext'])); + self::assertStringStartsWith('

Here we go.

', $detail['responsetext']); + self::assertCount(0, $detail['attachments']); + } + } + + public function test_pdf_from_html_when_input_is_plaintext_with_newlines(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + // Create a course and a quiz with an essay question. + $generator = $this->getDataGenerator(); + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $course = $generator->create_course(); + $quiz = $this->create_test_quiz($course); + quiz_essaydownload_test_helper::add_essay_question($questiongenerator, $quiz, [ + 'name' => 'My Question Title / Test', + 'questiontext' => ['text' => '

Go write your stuff!

', 'format' => FORMAT_HTML], + ]); + + // Add a student and start an attempt. + $student = $generator->create_user(); + $generator->enrol_user($student->id, $course->id, 'student'); + list($quizobj, $quba, $attemptobj) = quiz_essaydownload_test_helper::start_attempt_at_quiz($quiz, $student); + + // Submit a response and finish the attempt. + $timenow = time(); + $tosubmit = [1 => ['answer' => "Here\nwe\ngo.", 'answerformat' => FORMAT_PLAIN]]; + $attemptobj->process_submitted_actions($timenow, false, $tosubmit); + $attemptobj->process_finish($timenow, false); + + $cm = get_coursemodule_from_id('quiz', $quiz->cmid); + $report = new quiz_essaydownload_report(); + list($currentgroup, $allstudentjoins, $groupstudentjoins, $allowedjoins) = + $report->init('essaydownload', 'quiz_essaydownload_form', $quiz, $cm, $course); + + // Fetch the attemp using the report's API. + $fetchedattempts = $report->get_attempts_and_names($groupstudentjoins); + self::assertCount(1, $fetchedattempts); + + // Fetch the details. + $details = $report->get_details_for_attempt(array_keys($fetchedattempts)[0]); + + // We expect the result to be an array with one element. The data should match the + // second response. + self::assertCount(1, $details); + foreach ($details as $label => $detail) { + self::assertEquals('Question_1_-_My_Question_Title__Test', $label); + self::assertEquals('

Go write your stuff!

', trim($detail['questiontext'])); + self::assertStringStartsWith("Here
\nwe
\ngo.", $detail['responsetext']); + self::assertCount(0, $detail['attachments']); + } + } + + public function test_txt_when_input_is_html(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + // Create a course and a quiz with an essay question. + $generator = $this->getDataGenerator(); + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $course = $generator->create_course(); + $quiz = $this->create_test_quiz($course); + quiz_essaydownload_test_helper::add_essay_question($questiongenerator, $quiz, [ + 'name' => 'My Question Title / Test', + 'questiontext' => ['text' => '

Go write your stuff!

', 'format' => FORMAT_HTML], + ]); + + // Add a student and start an attempt. + $student = $generator->create_user(); + $generator->enrol_user($student->id, $course->id, 'student'); + list($quizobj, $quba, $attemptobj) = quiz_essaydownload_test_helper::start_attempt_at_quiz($quiz, $student); + + // Submit a response and finish the attempt. + $timenow = time(); + $tosubmit = [1 => ['answer' => '

Here we go.

', 'answerformat' => FORMAT_HTML]]; + $attemptobj->process_submitted_actions($timenow, false, $tosubmit); + $attemptobj->process_finish($timenow, false); + + $cm = get_coursemodule_from_id('quiz', $quiz->cmid); + $report = new quiz_essaydownload_report(); + list($currentgroup, $allstudentjoins, $groupstudentjoins, $allowedjoins) = + $report->init('essaydownload', 'quiz_essaydownload_form', $quiz, $cm, $course); + + // Use reflection to force options. + $reflectedreport = new \ReflectionClass($report); + $reflectedoptions = $reflectedreport->getProperty('options'); + $reflectedoptions->setAccessible(true); + $options = new quiz_essaydownload_options('essaydownload', $quiz, $cm, $course); + $options->fileformat = 'txt'; + $options->source = 'plain'; + $reflectedoptions->setValue($report, $options); + + // Fetch the attemp using the report's API. + $fetchedattempts = $report->get_attempts_and_names($groupstudentjoins); + self::assertCount(1, $fetchedattempts); + + // Fetch the details. + $details = $report->get_details_for_attempt(array_keys($fetchedattempts)[0]); + + // We expect the result to be an array with one element. The data should match the + // second response. + self::assertCount(1, $details); + foreach ($details as $label => $detail) { + self::assertEquals('Question_1_-_My_Question_Title__Test', $label); + self::assertEquals('Go write YOUR stuff!', trim($detail['questiontext'])); + self::assertStringStartsWith('Here WE go.', $detail['responsetext']); + self::assertCount(0, $detail['attachments']); + } + } + }