Skip to content

Commit 6e287a4

Browse files
committed
feat: send uploads when viewing or and grading
Closes: #232
1 parent 416af57 commit 6e287a4

File tree

7 files changed

+93
-63
lines changed

7 files changed

+93
-63
lines changed

classes/local/files/file_metadata.php renamed to classes/local/api/file_metadata.php

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,15 @@
1414
// You should have received a copy of the GNU General Public License
1515
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
1616

17-
namespace qtype_questionpy\local\files;
17+
namespace qtype_questionpy\local\api;
1818

1919
use core\exception\coding_exception;
2020
use DateTimeImmutable;
21+
use JsonSerializable;
22+
use qtype_questionpy\local\array_converter\array_converter;
2123
use qtype_questionpy\local\array_converter\attributes\array_key;
24+
use qtype_questionpy\local\array_converter\conversion_exception;
25+
use qtype_questionpy\local\files\qpy_file_ref;
2226
use stored_file;
2327

2428
/**
@@ -29,7 +33,7 @@
2933
* @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org}
3034
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
3135
*/
32-
class file_metadata {
36+
class file_metadata implements JsonSerializable {
3337
/**
3438
* Trivial constructor.
3539
*
@@ -78,4 +82,15 @@ public static function from_stored_file(stored_file $file, ?string $overridename
7882
size: $file->get_filesize(),
7983
);
8084
}
85+
/**
86+
* Specify data which should be serialized to JSON
87+
* @link https://php.net/manual/en/jsonserializable.jsonserialize.php
88+
* @return mixed data which can be serialized by <b>json_encode</b>,
89+
* which is a value of any type other than a resource.
90+
* @throws coding_exception
91+
* @throws conversion_exception
92+
*/
93+
public function jsonSerialize(): mixed {
94+
return array_converter::to_array($this);
95+
}
8196
}

classes/local/api/package_api.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,19 +134,21 @@ public function start_attempt(string $questionstate, int $variant, ?array $attri
134134
* @param string $attemptstate the attempt state previously returned from {@see start_attempt()}
135135
* @param string|null $scoringstate the last scoring state if this attempt has already been scored
136136
* @param object|null $response data currently entered by the student
137+
* @param array[]|null $uploads Lists of uploaded files by upload field name.
137138
* @param array|null $editors
138139
* @return attempt the attempt's metadata. The state is not returned since it never changes.
139140
* @throws GuzzleException
140141
* @throws moodle_exception
141142
* @throws request_error
142143
*/
143144
public function view_attempt(string $questionstate, ?array $attributes, string $attemptstate, ?string $scoringstate = null,
144-
?object $response = null, ?array $editors = null): attempt {
145+
?object $response = null, ?array $uploads = null, ?array $editors = null): attempt {
145146
$options['multipart'] = $this->transform_to_multipart(
146147
[
147148
'attempt_state' => $attemptstate,
148149
'scoring_state' => $scoringstate,
149150
'response' => $response,
151+
'uploads' => $uploads === null ? null : (object) $uploads,
150152
'editors' => $editors === null ? null : (object) $editors,
151153
'context' => $this->get_context_id(),
152154
'lms_provided_attributes' => $attributes,
@@ -165,19 +167,21 @@ public function view_attempt(string $questionstate, ?array $attributes, string $
165167
* @param string $attemptstate the attempt state previously returned from {@see start_attempt()}
166168
* @param string|null $scoringstate the last scoring state if this attempt had been scored before
167169
* @param object $response data submitted by the student
168-
* @param wysiwyg_editor_data[] $editors
170+
* @param array[] $uploads Lists of uploaded files by upload field name.
171+
* @param wysiwyg_editor_data[] $editors Editor data by editor name.
169172
* @return attempt_scored the attempt's metadata. The state is not returned since it never changes.
170173
* @throws GuzzleException
171174
* @throws moodle_exception
172175
* @throws request_error
173176
*/
174177
public function score_attempt(string $questionstate, ?array $attributes, string $attemptstate, ?string $scoringstate,
175-
object $response, array $editors): attempt_scored {
178+
object $response, array $uploads, array $editors): attempt_scored {
176179
$options['multipart'] = $this->transform_to_multipart(
177180
[
178181
'attempt_state' => $attemptstate,
179182
'scoring_state' => $scoringstate,
180183
'response' => $response,
184+
'uploads' => (object) $uploads,
181185
'editors' => (object) $editors,
182186
'generate_hint' => false,
183187
'context' => $this->get_context_id(),

classes/local/api/wysiwyg_editor_data.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
use qtype_questionpy\local\array_converter\attributes\array_element_class;
2323
use qtype_questionpy\local\array_converter\attributes\array_key;
2424
use qtype_questionpy\local\array_converter\conversion_exception;
25-
use qtype_questionpy\local\files\file_metadata;
2625

2726
/**
2827
* Data class for WYSIWYG editor data.

classes/local/files/response_file_service.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ public static function unmangle_filename(string $filename): array {
248248
*
249249
* @param array $response As returned by {@see question_attempt::get_last_qt_data()} and passed to
250250
* {@see qtype_questionpy_question::grade_response()}.
251-
* @return stored_file[] Files belonging to the response.
251+
* @return stored_file[][] Arrays à la `[$fieldname => [$filename => stored_file]]`.
252252
* @throws coding_exception
253253
*/
254254
public function get_all_files_from_qt_data(array $response): array {
@@ -263,6 +263,12 @@ public function get_all_files_from_qt_data(array $response): array {
263263
throw new coding_exception("The '$key' qt var exists, but is not an instance of question_response_files.");
264264
}
265265

266-
return $accessor->get_files();
266+
$filesbyfield = [];
267+
foreach ($accessor->get_files() as $file) {
268+
[$fieldname, $filename] = self::unmangle_filename($file->get_filename());
269+
$filesbyfield[$fieldname][$filename] = $file;
270+
}
271+
272+
return $filesbyfield;
267273
}
268274
}

classes/local/form/elements/file_upload_element.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,11 @@
2222
use moodle_exception;
2323
use MoodleQuickForm_filemanager;
2424
use qtype_questionpy\local\array_converter\array_converter;
25-
use qtype_questionpy\local\array_converter\attributes\array_key;
26-
use qtype_questionpy\local\files\file_metadata;
25+
use qtype_questionpy\local\api\file_metadata;
2726
use qtype_questionpy\local\files\options_file_service;
2827
use qtype_questionpy\local\form\context\render_context;
2928
use qtype_questionpy\local\form\form_help;
3029
use qtype_questionpy\utils;
31-
use stdClass;
3230

3331
/**
3432
* File upload.

classes/local/form/elements/wysiwyg_editor_element.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
use qtype_questionpy\local\api\wysiwyg_editor_data;
2828
use qtype_questionpy\local\array_converter\array_converter;
2929
use qtype_questionpy\local\array_converter\attributes\array_key;
30-
use qtype_questionpy\local\files\file_metadata;
30+
use qtype_questionpy\local\api\file_metadata;
3131
use qtype_questionpy\local\files\options_file_service;
3232
use qtype_questionpy\local\form\context\render_context;
3333
use qtype_questionpy\local\form\form_help;

question.php

Lines changed: 59 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
use qtype_questionpy\local\api\scoring_code;
3333
use qtype_questionpy\local\api\wysiwyg_editor_data;
3434
use qtype_questionpy\local\attempt_ui\question_ui_metadata_extractor;
35-
use qtype_questionpy\local\files\file_metadata;
35+
use qtype_questionpy\local\api\file_metadata;
3636
use qtype_questionpy\local\files\response_file_service;
3737
use qtype_questionpy\question_bridge_base;
3838
use qtype_questionpy\utils;
@@ -201,25 +201,19 @@ public function apply_attempt_state(question_attempt_step $step) {
201201
question_attempt->start_question_based_on, where we shouldn't need to get the UI. */
202202
try {
203203
$lastresponsestep = $qa->get_last_step_with_qt_var(constants::QT_VAR_RESPONSE);
204-
$lastresponse = utils::get_qpy_response($lastresponsestep->get_qt_data());
205-
206-
$allfiles = $this->rfs->get_all_files_from_qt_data($lastresponsestep->get_qt_data());
207-
$editors = utils::get_qpy_editors_data($lastresponsestep->get_qt_data());
208-
array_walk(
209-
$editors,
210-
fn(&$editordata, $editorname) => $editordata = $this->build_wysiwyg_data($editorname, $editordata, $allfiles)
211-
);
204+
[$lastresponse, $uploads, $editors] = $this->prepare_responses_for_server($lastresponsestep->get_qt_data());
212205

213206
$attributes = $this->get_requested_attributes();
214207

215208
$attempt = $this->api->package($this->packagehash, $this->packagefile)
216209
->view_attempt(
217-
$this->questionstate,
218-
$attributes,
219-
$this->attemptstate,
220-
$this->scoringstate,
221-
$lastresponse,
222-
$editors,
210+
questionstate: $this->questionstate,
211+
attributes: $attributes,
212+
attemptstate: $this->attemptstate,
213+
scoringstate: $this->scoringstate,
214+
response: $lastresponse,
215+
uploads: $uploads,
216+
editors: $editors,
223217
);
224218
$this->update_attempt($attempt);
225219
$this->errorduringload = false;
@@ -444,30 +438,6 @@ public function get_validation_error(array $response) {
444438
return '';
445439
}
446440

447-
/**
448-
* Joins the raw editor data with the files that belong to it ands returns a {@see wysiwyg_editor_data} object.
449-
*
450-
* @param string $editorname
451-
* @param object $rawdata
452-
* @param stored_file[] $allfiles
453-
* @return wysiwyg_editor_data
454-
* @throws coding_exception
455-
*/
456-
private function build_wysiwyg_data(string $editorname, object $rawdata, array $allfiles): wysiwyg_editor_data {
457-
$filemetas = [];
458-
foreach (response_file_service::filter_combined_files_for_field($allfiles, $editorname) as $filename => $file) {
459-
$filemetas[] = file_metadata::from_stored_file($file, overridename: $filename);
460-
}
461-
462-
// TODO: Turn @@PLUGINFILE@@-links into QPy-URLs?
463-
464-
return new wysiwyg_editor_data(
465-
text: $rawdata->text,
466-
textformat: $rawdata->format,
467-
files: $filemetas,
468-
);
469-
}
470-
471441
/**
472442
* Grade a response to the question, returning a fraction between
473443
* get_min_fraction() and get_max_fraction(), and the corresponding {@see question_state}
@@ -485,20 +455,16 @@ public function grade_response(array $response): array {
485455
try {
486456
$attributes = $this->get_requested_attributes();
487457

488-
$allfiles = $this->rfs->get_all_files_from_qt_data($response);
489-
$editors = utils::get_qpy_editors_data($response);
490-
array_walk(
491-
$editors,
492-
fn(&$editordata, $editorname) => $editordata = $this->build_wysiwyg_data($editorname, $editordata, $allfiles)
493-
);
458+
[$qpyresponse, $uploads, $editors] = $this->prepare_responses_for_server($response);
494459

495460
$attemptscored = $this->api->package($this->packagehash, $this->packagefile)->score_attempt(
496-
$this->questionstate,
497-
$attributes,
498-
$this->attemptstate,
499-
$this->scoringstate,
500-
utils::get_qpy_response($response) ?? (object)[],
501-
$editors
461+
questionstate: $this->questionstate,
462+
attributes: $attributes,
463+
attemptstate: $this->attemptstate,
464+
scoringstate: $this->scoringstate,
465+
response: $qpyresponse ?? (object)[],
466+
uploads: $uploads,
467+
editors: $editors,
502468
);
503469
$this->update_attempt($attemptscored);
504470
} catch (Throwable $t) {
@@ -537,6 +503,48 @@ public function grade_response(array $response): array {
537503
return [$attemptscored->score, $newqstate];
538504
}
539505

506+
/**
507+
* Converts the given QT data to the response, uploads, and editors that are expected by the QuestionPy server.
508+
*
509+
* @param array $responseqtdata The qt data that is being scored or viewed.
510+
* @return array A tuple of `[$response, $uploads, $editors]`.
511+
* @throws coding_exception
512+
*/
513+
private function prepare_responses_for_server(array $responseqtdata): array {
514+
$lastresponse = utils::get_qpy_response($responseqtdata);
515+
516+
$filesbyfield = $this->rfs->get_all_files_from_qt_data($responseqtdata);
517+
$raweditors = utils::get_qpy_editors_data($responseqtdata);
518+
519+
$editors = [];
520+
foreach ($raweditors as $editorname => $editordata) {
521+
$filemetas = [];
522+
if (isset($filesbyfield[$editorname])) {
523+
foreach ($filesbyfield[$editorname] as $filename => $file) {
524+
$filemetas[] = file_metadata::from_stored_file($file, overridename: $filename);
525+
}
526+
}
527+
528+
// TODO: Turn @@PLUGINFILE@@-links into QPy-URLs?
529+
530+
$editors[$editorname] = new wysiwyg_editor_data(
531+
text: $editordata->text,
532+
textformat: $editordata->format,
533+
files: $filemetas,
534+
);
535+
}
536+
537+
$uploads = [];
538+
// Any files that don't belong to editors must belong to file upload elements.
539+
foreach (array_diff_key($filesbyfield, $editors) as $fieldname => $files) {
540+
foreach ($files as $filename => $file) {
541+
$uploads[$fieldname][] = file_metadata::from_stored_file($file, overridename: $filename);
542+
}
543+
}
544+
545+
return [$lastresponse, $uploads, $editors];
546+
}
547+
540548
/**
541549
* Work out a final grade for this attempt, taking into account all the
542550
* tries the student made.

0 commit comments

Comments
 (0)