Skip to content

Commit ec0fc89

Browse files
committed
Add files attribute to combinator grader outcome, to allow replacement of data urls with urls that reference files saved in the Moodle file system.
1 parent af2fda9 commit ec0fc89

File tree

3 files changed

+192
-38
lines changed

3 files changed

+192
-38
lines changed

Readme.md

+16-1
Original file line numberDiff line numberDiff line change
@@ -1954,7 +1954,7 @@ template grader", i.e. a TemplateGrader with the `Is combinator` checkbox checke
19541954
In this mode, the JSON string output by the template grader
19551955
should again contain a 'fraction' field, this time for the total mark,
19561956
and may contain zero or more of 'prologuehtml', 'testresults', 'columnformats',
1957-
'epiloguehtml', 'showoutputonly', 'showdifferences' and 'graderstate'.
1957+
'epiloguehtml', 'instructorhtml', 'files', 'showoutputonly', 'showdifferences' and 'graderstate'.
19581958
attributes.
19591959
The 'prologuehtml' and 'epiloguehtml' fields are html
19601960
that is displayed respectively before and after the (optional) result table. The
@@ -1967,6 +1967,9 @@ actually displayed but 0 or 1 values in the column can be used to turn on and
19671967
off row visibility. Students do not see hidden rows but markers and other
19681968
staff do.
19691969

1970+
'instructorhtml' is a special version of 'epiloguehtml' that is displayed only
1971+
to teachers.
1972+
19701973
If a 'testresults' field is present, there can also be a 'columnformats' field.
19711974
This should have one format specifier per table column and each format specifier
19721975
should either be '%s', in which case all formatting is left to the renderer
@@ -1990,6 +1993,18 @@ the standard 'Show differences' button after the result table; it is displayed
19901993
only if there is actually a result table present and if full marks were not
19911994
awarded to the question.
19921995

1996+
The 'files' attribute is a JSON object mapping from filenames to the corresponding
1997+
base4 encoded
1998+
file contents. This parameter is intended primarily for returning image files
1999+
that will be displayed in the feedback, but could have other uses. If a 'files'
2000+
attribute is present, the files are written to the Moodle file area and download
2001+
URLs generated. files are timestamped so the same filename can be used unambiguously
2002+
in multiple grade responses. The URLs are then used to update any occurrences of the strings
2003+
`src="filename"` or `href="filename"` within the 'prologuehtml', 'testresults',
2004+
'epiloguehtml' and 'instructorhtml' attributes to use the full URL instead of just the
2005+
filename. Unmatched filenames are disregarded. Single quotes instead of double
2006+
quotes can also be used in the 'src' and 'href' attribute assignments.
2007+
19932008
The 'graderstate' attribute is a string value that is stored in the database
19942009
with the question attempt and is passed back to the combinator template grader
19952010
code on the next attempt of that question as the field 'graderstate' of the

classes/combinator_grader_outcome.php

+158-37
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ class qtype_coderunner_combinator_grader_outcome extends qtype_coderunner_testin
3232
/** @var ?string Html that is displayed after the result table. */
3333
public $prologuehtml;
3434

35+
/** @var array A map from filename to feedback file contents, base64 */
36+
public $files;
37+
38+
/** @var array A map from filename to the URLs of the saved files */
39+
public $fileurls;
40+
3541
/** @var array A per-column array of %s (string) or %h (html) values to control column formatting */
3642
public $columnformats;
3743

@@ -50,14 +56,14 @@ class qtype_coderunner_combinator_grader_outcome extends qtype_coderunner_testin
5056
/** @var array Array where each item is a rows of test result table */
5157
public $testresults;
5258

53-
/** @var ?string The feedback for a given question attempt */
59+
/** @var ?string The feedback for a given question attempt (legacy support only) */
5460
public $feedbackhtml;
5561

5662
/** @var bool Whether or no show differences is selected */
5763
public $showdifferences;
5864

5965
// A list of the allowed attributes in the combinator template grader return value.
60-
public $allowedfields = ['fraction', 'prologuehtml', 'testresults', 'epiloguehtml',
66+
public $allowedfields = ['fraction', 'prologuehtml', 'testresults', 'files', 'epiloguehtml',
6167
'feedbackhtml', 'columnformats', 'showdifferences',
6268
'showoutputonly', 'graderstate', 'instructorhtml',
6369
];
@@ -74,20 +80,109 @@ public function __construct($isprecheck) {
7480
}
7581

7682

83+
/**
84+
* Process all the files in $files, saving them to the Moodle file area
85+
* and generating an URL to reference the saved file. The URLs are
86+
* time-stamped to allow re-use of the same name over
87+
* multiple submissions of a question.
88+
* @param array $files An associate array mapping filenames to base64-encoded contents.
89+
* @param array An associate array mapping filenames to URLs that reference that file.
90+
*/
91+
private function save_files($files) {
92+
global $USER;
93+
94+
$fileurls = [];
95+
foreach ($files as $filename => $base64) {
96+
$decoded = base64_decode($base64);
97+
98+
// Prepare file record object
99+
$contextid = context_user::instance($USER->id)->id;
100+
$fileinfo = array(
101+
'contextid' => $contextid,
102+
'component' => 'qtype_coderunner',
103+
'filearea' => 'feedbackfiles', // Custom file area for your plugin
104+
'itemid' => time(), // Item ID - use Unix time stamp
105+
'filepath' => '/', // File path within the context
106+
'filename' => $filename); // Desired name of the file
107+
108+
// Get the file storage object
109+
$fs = get_file_storage();
110+
111+
// Check if the file already exists to avoid duplicates
112+
if (!$fs->file_exists($contextid, $fileinfo['component'], $fileinfo['filearea'],
113+
$fileinfo['itemid'], $fileinfo['filepath'], $fileinfo['filename'])) {
114+
115+
// Create the file in Moodle's filesystem
116+
$file = $fs->create_file_from_string($fileinfo, $decoded);
117+
}
118+
119+
// Generate a URL to the saved file
120+
$url = moodle_url::make_pluginfile_url($contextid, $fileinfo['component'], $fileinfo['filearea'],
121+
$fileinfo['itemid'], $fileinfo['filepath'], $fileinfo['filename'], false);
122+
123+
$fileurls[$filename] = $url;
124+
}
125+
return $fileurls;
126+
}
127+
128+
129+
/**
130+
* Replace any occurrences of substrings of the form src="filename" or
131+
* href=filename within the given html string to reference the URL of
132+
* the file, if the filename is found in $this->fileurls.
133+
* No action is taken for non-matching filename.
134+
* @param string html The html string to be updates.
135+
* @param array $urls An associative array mapping from filenames to URLs
136+
* @return string The html string with the above-described substitutions made.
137+
*/
138+
private function insert_file_urls($html, $urls) {
139+
if ($urls) {
140+
foreach($urls as $filename => $url) {
141+
$filename = preg_quote($filename, '/');
142+
$patterns = [
143+
"src *= *'$filename'",
144+
"src *= *\"$filename\"",
145+
"href *= *'$filename'",
146+
"href *= *\"$filename\""
147+
];
148+
foreach($patterns as $pat) {
149+
if (strpos($pat, 'src') !== false) {
150+
$html = preg_replace("/$pat/", "src=\"$url\"", $html);
151+
} else {
152+
$html = preg_replace("/$pat/", "href=\"$url\"", $html);
153+
}
154+
}
155+
}
156+
}
157+
return $html; // return the modified HTML
158+
}
159+
160+
77161
/**
78162
* Method to set the mark and the various feedback values (prologuehtml,
79163
* testresults, columnformats, epiloguehtml, graderstate).
80164
* @param float $markfraction the mark in the range 0 - 1
81-
* @param array $feedback Associative array of attributes to add to the
82-
* outcome object, usually zero or more of prologuehtml, testresults,
83-
* columnformats and epiloguehtml.
165+
* @param array $feedback Associative array of additional attributes as
166+
* listed in the $this->allowedfields.
84167
*/
85168
public function set_mark_and_feedback($markfraction, $feedback) {
86169
$this->actualmark = $markfraction; // Combinators work in the range 0 - 1.
87-
foreach ($feedback as $key => $value) {
88-
$this->$key = $value;
170+
$columnformats = $feedback['columnformats'] ?? null;
171+
$testresults = $feedback['testresults'] ?? null;
172+
$files = $feedback['files'] ?? null;
173+
$urls = null;
174+
if ($this->valid_table_formats($testresults, $columnformats)) {
175+
if ($files) {
176+
$urls = $this->save_files($files);
177+
$htmlfields = ['feedbackhtml', 'prologuehtml', 'epiloguehtml', 'instructorhtml'];
178+
foreach($htmlfields as $field) {
179+
if ($this->$field) {
180+
$this->$field = $this->insert_file_urls($this->$field, $urls);
181+
}
182+
};
183+
}
184+
$this->format_results_table($testresults, $columnformats, $urls);
89185
}
90-
$this->validate_table_formats();
91186
}
92187

93188

@@ -253,29 +348,33 @@ public function validation_error_message() {
253348
*/
254349
public function get_test_results(qtype_coderunner_question $q) {
255350
if (empty($this->testresults) || self::can_view_hidden()) {
256-
return $this->format_table($this->testresults);
351+
return $this->testresults;
257352
} else {
258-
return $this->format_table($this->visible_rows($this->testresults));
353+
return self::visible_rows($this->testresults);
259354
}
260355
}
261356

262-
// Function to apply the formatting specified in $this->columnformats
263-
// to the given table. This simply wraps cells in columns with a '%h' format
264-
// specifier in html_wrapper objects leaving other cells unchanged.
265-
// ishidden and iscorrect columns are copied across unchanged.
266-
private function format_table($table) {
267-
if (empty($table)) {
268-
return $table;
269-
}
270-
if (!$this->columnformats) {
271-
$newtable = $table;
357+
/**
358+
* Build the testresults table from the initial given testresults by
359+
* applying all the column formats. Cells with '%h' format are first
360+
* processed to replace any src or href assignments with references to
361+
* the $urls of the saved files. Then the cells are wrapped in
362+
* html_wrapper objects. All other cells are unchanged.
363+
* ishidden and iscorrect columns are copied across unchanged.
364+
* @param array $testresults The 'raw' $testresults from the run.
365+
* @param array $columnformats The array of '%s' or '%h' column formats
366+
* @param array $urls. The map from filename to URL
367+
*/
368+
private function format_results_table($testresults, $columnformats, $urls) {
369+
if (!$testresults || !$columnformats) {
370+
$this->testresults = $testresults;
272371
} else {
273-
$formats = $this->columnformats;
274-
$columnheaders = $table[0];
372+
$formats = $columnformats;
373+
$columnheaders = $testresults[0];
275374
$newtable = [$columnheaders];
276-
$nrows = count($table);
375+
$nrows = count($testresults);
277376
for ($i = 1; $i < $nrows; $i++) {
278-
$row = $table[$i];
377+
$row = $testresults[$i];
279378
$newrow = [];
280379
$formatindex = 0;
281380
$ncols = count($row);
@@ -284,8 +383,10 @@ private function format_table($table) {
284383
if (in_array($columnheaders[$col], ['ishidden', 'iscorrect'])) {
285384
$newrow[] = $cell; // Copy control column values directly.
286385
} else {
386+
287387
// Non-control columns are either '%s' or '%h' format.
288388
if ($formats[$formatindex++] === '%h') {
389+
$cell = $this->insert_file_urls($cell, $urls);
289390
$newrow[] = new qtype_coderunner_html_wrapper($cell);
290391
} else {
291392
$newrow[] = $cell;
@@ -295,7 +396,7 @@ private function format_table($table) {
295396
$newtable[] = $newrow;
296397
}
297398
}
298-
return $newtable;
399+
$this->testresults = $newtable;
299400
}
300401

301402
public function get_prologue() {
@@ -326,45 +427,65 @@ public function get_grader_state() {
326427
}
327428

328429

329-
// Check that if a columnformats field is supplied
330-
// the number of entries is correct and that each entry is either '%s'
331-
// or '%h'. If not, an appropriate status error message is set.
332-
private function validate_table_formats() {
333-
if ($this->columnformats && $this->testresults) {
430+
/**
431+
* Check if the given values of column formats and test results are
432+
* valid in the sense that the number of entries in column formats
433+
* matches the number of columns in the table and that each column
434+
* foramt is either '%s'
435+
* or '%h'. If not, an appropriate status error message is set and
436+
* the return value is false. Otherwise the return value is true.
437+
* @param array $testresults An array of test results, where the
438+
* first row is the column headers and the remaining rows are the
439+
* results of each test.
440+
* @param array $columnformats An array of strings specifying the
441+
* column formats, each either %s or %h.
442+
* @return bool True if either of $columnformats or $testresults is
443+
* null or empty or if all column formats are valid. Otherwise
444+
* return false, and set an error status.
445+
*/
446+
447+
private static function valid_table_formats($testresults,$columnformats, ) {
448+
$ok = true;
449+
if ($columnformats && $testresults) {
334450
$numcols = 0;
335-
foreach ($this->testresults[0] as $colhdr) {
451+
foreach ($testresults[0] as $colhdr) {
336452
// Count columns in header, excluding iscorrect and ishidden.
337453
if ($colhdr !== 'iscorrect' && $colhdr !== 'ishidden') {
338454
$numcols += 1;
339455
}
340456
}
341-
if (count($this->columnformats) !== $numcols) {
457+
if (count($columnformats) !== $numcols) {
342458
$error = get_string(
343459
'wrongnumberofformats',
344460
'qtype_coderunner',
345-
['expected' => $numcols, 'got' => count($this->columnformats)]
461+
['expected' => $numcols, 'got' => count($columnformats)]
346462
);
347463
$this->set_status(self::STATUS_BAD_COMBINATOR, $error);
464+
$ok = false;
348465
} else {
349-
foreach ($this->columnformats as $format) {
466+
foreach ($columnformats as $format) {
350467
if ($format !== '%s' && $format !== '%h') {
351468
$error = get_string(
352469
'illegalformat',
353470
'qtype_coderunner',
354471
['format' => $format]
355472
);
356473
$this->set_status(self::STATUS_BAD_COMBINATOR, $error);
474+
$ok = false;
357475
break;
358476
}
359477
}
360478
}
361479
}
480+
return $ok;
362481
}
363482

364483

365-
// Private method to filter result table so only visible rows are shown
366-
// to students. Only called if the user is not allowed to see hidden rows
367-
// And if there is a non-null non-empty resulttable.
484+
/**
485+
* Filter the given result table to return only the visible rows.
486+
* Only called if the user is not allowed to see hidden rows
487+
* And if there is a non-null non-empty resulttable.
488+
* */
368489
private static function visible_rows($resulttable) {
369490
$header = $resulttable[0];
370491
$ishiddencolumn = -1;

lib.php

+18
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,24 @@
3535
*/
3636
function qtype_coderunner_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options = []) {
3737
global $CFG;
38+
if ($filearea === 'feedbackfiles') {
39+
require_login($course, false, $cm);
40+
41+
// Note: Implement additional checks here, as needed, to ensure users are authorized to access the file
42+
43+
// Serve the file
44+
$fs = get_file_storage();
45+
$filename = array_pop($args);
46+
$itemid = intval(array_shift($args));
47+
$filepath = '/';
48+
$contextid = $context->id;
49+
$file = $fs->get_file($contextid, 'qtype_coderunner', $filearea, $itemid, $filepath, $filename);
50+
if (!$file) {
51+
send_file_not_found();
52+
}
53+
send_stored_file($file, 0, 0, $forcedownload, $options); // Adjust options as necessary
54+
}
3855
require_once($CFG->libdir . '/questionlib.php');
3956
question_pluginfile($course, $context, 'qtype_coderunner', $filearea, $args, $forcedownload, $options);
4057
}
58+

0 commit comments

Comments
 (0)