diff --git a/classes/local/files/file_metadata.php b/classes/local/api/file_metadata.php
similarity index 79%
rename from classes/local/files/file_metadata.php
rename to classes/local/api/file_metadata.php
index f6ce66e0..4794ab89 100644
--- a/classes/local/files/file_metadata.php
+++ b/classes/local/api/file_metadata.php
@@ -14,11 +14,15 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see .
-namespace qtype_questionpy\local\files;
+namespace qtype_questionpy\local\api;
use core\exception\coding_exception;
use DateTimeImmutable;
+use JsonSerializable;
+use qtype_questionpy\local\array_converter\array_converter;
use qtype_questionpy\local\array_converter\attributes\array_key;
+use qtype_questionpy\local\array_converter\conversion_exception;
+use qtype_questionpy\local\files\qpy_file_ref;
use stored_file;
/**
@@ -29,7 +33,7 @@
* @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class file_metadata {
+class file_metadata implements JsonSerializable {
/**
* Trivial constructor.
*
@@ -78,4 +82,15 @@ public static function from_stored_file(stored_file $file, ?string $overridename
size: $file->get_filesize(),
);
}
+ /**
+ * Specify data which should be serialized to JSON
+ * @link https://php.net/manual/en/jsonserializable.jsonserialize.php
+ * @return mixed data which can be serialized by json_encode,
+ * which is a value of any type other than a resource.
+ * @throws coding_exception
+ * @throws conversion_exception
+ */
+ public function jsonSerialize(): mixed {
+ return array_converter::to_array($this);
+ }
}
diff --git a/classes/local/api/package_api.php b/classes/local/api/package_api.php
index 6ea06c05..1cce4535 100644
--- a/classes/local/api/package_api.php
+++ b/classes/local/api/package_api.php
@@ -134,6 +134,7 @@ public function start_attempt(string $questionstate, int $variant, ?array $attri
* @param string $attemptstate the attempt state previously returned from {@see start_attempt()}
* @param string|null $scoringstate the last scoring state if this attempt has already been scored
* @param object|null $response data currently entered by the student
+ * @param array[]|null $uploads Lists of uploaded files by upload field name.
* @param array|null $editors
* @return attempt the attempt's metadata. The state is not returned since it never changes.
* @throws GuzzleException
@@ -141,12 +142,13 @@ public function start_attempt(string $questionstate, int $variant, ?array $attri
* @throws request_error
*/
public function view_attempt(string $questionstate, ?array $attributes, string $attemptstate, ?string $scoringstate = null,
- ?object $response = null, ?array $editors = null): attempt {
+ ?object $response = null, ?array $uploads = null, ?array $editors = null): attempt {
$options['multipart'] = $this->transform_to_multipart(
[
'attempt_state' => $attemptstate,
'scoring_state' => $scoringstate,
'response' => $response,
+ 'uploads' => $uploads === null ? null : (object) $uploads,
'editors' => $editors === null ? null : (object) $editors,
'context' => $this->get_context_id(),
'lms_provided_attributes' => $attributes,
@@ -165,19 +167,21 @@ public function view_attempt(string $questionstate, ?array $attributes, string $
* @param string $attemptstate the attempt state previously returned from {@see start_attempt()}
* @param string|null $scoringstate the last scoring state if this attempt had been scored before
* @param object $response data submitted by the student
- * @param wysiwyg_editor_data[] $editors
+ * @param array[] $uploads Lists of uploaded files by upload field name.
+ * @param wysiwyg_editor_data[] $editors Editor data by editor name.
* @return attempt_scored the attempt's metadata. The state is not returned since it never changes.
* @throws GuzzleException
* @throws moodle_exception
* @throws request_error
*/
public function score_attempt(string $questionstate, ?array $attributes, string $attemptstate, ?string $scoringstate,
- object $response, array $editors): attempt_scored {
+ object $response, array $uploads, array $editors): attempt_scored {
$options['multipart'] = $this->transform_to_multipart(
[
'attempt_state' => $attemptstate,
'scoring_state' => $scoringstate,
'response' => $response,
+ 'uploads' => (object) $uploads,
'editors' => (object) $editors,
'generate_hint' => false,
'context' => $this->get_context_id(),
diff --git a/classes/local/api/wysiwyg_editor_data.php b/classes/local/api/wysiwyg_editor_data.php
index abf0366a..ae8e8228 100644
--- a/classes/local/api/wysiwyg_editor_data.php
+++ b/classes/local/api/wysiwyg_editor_data.php
@@ -22,7 +22,6 @@
use qtype_questionpy\local\array_converter\attributes\array_element_class;
use qtype_questionpy\local\array_converter\attributes\array_key;
use qtype_questionpy\local\array_converter\conversion_exception;
-use qtype_questionpy\local\files\file_metadata;
/**
* Data class for WYSIWYG editor data.
diff --git a/classes/local/files/response_file_service.php b/classes/local/files/response_file_service.php
index 2dc78140..9fd506c3 100644
--- a/classes/local/files/response_file_service.php
+++ b/classes/local/files/response_file_service.php
@@ -248,7 +248,7 @@ public static function unmangle_filename(string $filename): array {
*
* @param array $response As returned by {@see question_attempt::get_last_qt_data()} and passed to
* {@see qtype_questionpy_question::grade_response()}.
- * @return stored_file[] Files belonging to the response.
+ * @return stored_file[][] Arrays à la `[$fieldname => [$filename => stored_file]]`.
* @throws coding_exception
*/
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 {
throw new coding_exception("The '$key' qt var exists, but is not an instance of question_response_files.");
}
- return $accessor->get_files();
+ $filesbyfield = [];
+ foreach ($accessor->get_files() as $file) {
+ [$fieldname, $filename] = self::unmangle_filename($file->get_filename());
+ $filesbyfield[$fieldname][$filename] = $file;
+ }
+
+ return $filesbyfield;
}
}
diff --git a/classes/local/form/elements/file_upload_element.php b/classes/local/form/elements/file_upload_element.php
index 4d5ee436..526bc42b 100644
--- a/classes/local/form/elements/file_upload_element.php
+++ b/classes/local/form/elements/file_upload_element.php
@@ -22,13 +22,11 @@
use moodle_exception;
use MoodleQuickForm_filemanager;
use qtype_questionpy\local\array_converter\array_converter;
-use qtype_questionpy\local\array_converter\attributes\array_key;
-use qtype_questionpy\local\files\file_metadata;
+use qtype_questionpy\local\api\file_metadata;
use qtype_questionpy\local\files\options_file_service;
use qtype_questionpy\local\form\context\render_context;
use qtype_questionpy\local\form\form_help;
use qtype_questionpy\utils;
-use stdClass;
/**
* File upload.
diff --git a/classes/local/form/elements/wysiwyg_editor_element.php b/classes/local/form/elements/wysiwyg_editor_element.php
index c072e48c..609370c0 100644
--- a/classes/local/form/elements/wysiwyg_editor_element.php
+++ b/classes/local/form/elements/wysiwyg_editor_element.php
@@ -27,7 +27,7 @@
use qtype_questionpy\local\api\wysiwyg_editor_data;
use qtype_questionpy\local\array_converter\array_converter;
use qtype_questionpy\local\array_converter\attributes\array_key;
-use qtype_questionpy\local\files\file_metadata;
+use qtype_questionpy\local\api\file_metadata;
use qtype_questionpy\local\files\options_file_service;
use qtype_questionpy\local\form\context\render_context;
use qtype_questionpy\local\form\form_help;
diff --git a/edit_questionpy_form.php b/edit_questionpy_form.php
index 9629efd7..b9a3a828 100644
--- a/edit_questionpy_form.php
+++ b/edit_questionpy_form.php
@@ -632,7 +632,7 @@ public function validation($data, $files) {
$errors = parent::validation($data, $files);
$package = $this->validate_selected_package($data, $errors);
- if ($data['qpy_package_selected']) {
+ if ($data['qpy_package_selected'] && !($data['qpy_package_invalid'] ?? 0)) {
// The options form of a package is being submitted.
$this->validate_options_form($data, $package, $errors);
}
diff --git a/question.php b/question.php
index 9a595500..81f757fe 100644
--- a/question.php
+++ b/question.php
@@ -32,7 +32,7 @@
use qtype_questionpy\local\api\scoring_code;
use qtype_questionpy\local\api\wysiwyg_editor_data;
use qtype_questionpy\local\attempt_ui\question_ui_metadata_extractor;
-use qtype_questionpy\local\files\file_metadata;
+use qtype_questionpy\local\api\file_metadata;
use qtype_questionpy\local\files\response_file_service;
use qtype_questionpy\question_bridge_base;
use qtype_questionpy\utils;
@@ -201,25 +201,19 @@ public function apply_attempt_state(question_attempt_step $step) {
question_attempt->start_question_based_on, where we shouldn't need to get the UI. */
try {
$lastresponsestep = $qa->get_last_step_with_qt_var(constants::QT_VAR_RESPONSE);
- $lastresponse = utils::get_qpy_response($lastresponsestep->get_qt_data());
-
- $allfiles = $this->rfs->get_all_files_from_qt_data($lastresponsestep->get_qt_data());
- $editors = utils::get_qpy_editors_data($lastresponsestep->get_qt_data());
- array_walk(
- $editors,
- fn(&$editordata, $editorname) => $editordata = $this->build_wysiwyg_data($editorname, $editordata, $allfiles)
- );
+ [$lastresponse, $uploads, $editors] = $this->prepare_responses_for_server($lastresponsestep->get_qt_data());
$attributes = $this->get_requested_attributes();
$attempt = $this->api->package($this->packagehash, $this->packagefile)
->view_attempt(
- $this->questionstate,
- $attributes,
- $this->attemptstate,
- $this->scoringstate,
- $lastresponse,
- $editors,
+ questionstate: $this->questionstate,
+ attributes: $attributes,
+ attemptstate: $this->attemptstate,
+ scoringstate: $this->scoringstate,
+ response: $lastresponse,
+ uploads: $uploads,
+ editors: $editors,
);
$this->update_attempt($attempt);
$this->errorduringload = false;
@@ -444,30 +438,6 @@ public function get_validation_error(array $response) {
return '';
}
- /**
- * Joins the raw editor data with the files that belong to it ands returns a {@see wysiwyg_editor_data} object.
- *
- * @param string $editorname
- * @param object $rawdata
- * @param stored_file[] $allfiles
- * @return wysiwyg_editor_data
- * @throws coding_exception
- */
- private function build_wysiwyg_data(string $editorname, object $rawdata, array $allfiles): wysiwyg_editor_data {
- $filemetas = [];
- foreach (response_file_service::filter_combined_files_for_field($allfiles, $editorname) as $filename => $file) {
- $filemetas[] = file_metadata::from_stored_file($file, overridename: $filename);
- }
-
- // TODO: Turn @@PLUGINFILE@@-links into QPy-URLs?
-
- return new wysiwyg_editor_data(
- text: $rawdata->text,
- textformat: $rawdata->format,
- files: $filemetas,
- );
- }
-
/**
* Grade a response to the question, returning a fraction between
* get_min_fraction() and get_max_fraction(), and the corresponding {@see question_state}
@@ -485,20 +455,16 @@ public function grade_response(array $response): array {
try {
$attributes = $this->get_requested_attributes();
- $allfiles = $this->rfs->get_all_files_from_qt_data($response);
- $editors = utils::get_qpy_editors_data($response);
- array_walk(
- $editors,
- fn(&$editordata, $editorname) => $editordata = $this->build_wysiwyg_data($editorname, $editordata, $allfiles)
- );
+ [$qpyresponse, $uploads, $editors] = $this->prepare_responses_for_server($response);
$attemptscored = $this->api->package($this->packagehash, $this->packagefile)->score_attempt(
- $this->questionstate,
- $attributes,
- $this->attemptstate,
- $this->scoringstate,
- utils::get_qpy_response($response) ?? (object)[],
- $editors
+ questionstate: $this->questionstate,
+ attributes: $attributes,
+ attemptstate: $this->attemptstate,
+ scoringstate: $this->scoringstate,
+ response: $qpyresponse ?? (object)[],
+ uploads: $uploads,
+ editors: $editors,
);
$this->update_attempt($attemptscored);
} catch (Throwable $t) {
@@ -537,6 +503,63 @@ public function grade_response(array $response): array {
return [$attemptscored->score, $newqstate];
}
+ /**
+ * Converts the given QT data to the response, uploads, and editors that are expected by the QuestionPy server.
+ *
+ * @param array $responseqtdata The qt data that is being scored or viewed.
+ * @return array A tuple of `[$response, $uploads, $editors]`.
+ * @throws coding_exception
+ */
+ private function prepare_responses_for_server(array $responseqtdata): array {
+ $lastresponse = utils::get_qpy_response($responseqtdata);
+
+ $filesbyfield = $this->rfs->get_all_files_from_qt_data($responseqtdata);
+ $raweditors = utils::get_qpy_editors_data($responseqtdata);
+
+ $editors = [];
+ foreach ($raweditors as $editorname => $editordata) {
+ $text = $editordata->text;
+ $filemetas = [];
+
+ if (isset($filesbyfield[$editorname])) {
+ $filenamestorefs = [];
+
+ foreach ($filesbyfield[$editorname] as $filename => $file) {
+ $filemetas[] = $filemeta = file_metadata::from_stored_file($file, overridename: $filename);
+ $filenamestorefs[$filename] = $filemeta->fileref;
+ }
+
+ // Filenames may be prefixes of each other, so we replace the longest ones first.
+ uksort($filenamestorefs, fn($a, $b) => strlen($b) - strlen($a));
+ foreach ($filenamestorefs as $filename => $fileref) {
+ $text = str_replace('@@PLUGINFILE@@/' . $filename, 'qpy://response/' . $fileref, $text);
+ }
+ }
+
+ if (str_contains($text, '@@PLUGINFILE@@')) {
+ debugging('Editor text still contains @@PLUGINFILE@@-placeholders after replacement.');
+ $brokenfile = (new moodle_url('/brokenfile.php'))->out();
+ $text = str_replace('@@PLUGINFILE@@', $brokenfile, $text);
+ }
+
+ $editors[$editorname] = new wysiwyg_editor_data(
+ text: $text,
+ textformat: $editordata->format,
+ files: $filemetas,
+ );
+ }
+
+ $uploads = [];
+ // Any files that don't belong to editors must belong to file upload elements.
+ foreach (array_diff_key($filesbyfield, $editors) as $fieldname => $files) {
+ foreach ($files as $filename => $file) {
+ $uploads[$fieldname][] = file_metadata::from_stored_file($file, overridename: $filename);
+ }
+ }
+
+ return [$lastresponse, $uploads, $editors];
+ }
+
/**
* Work out a final grade for this attempt, taking into account all the
* tries the student made.