Skip to content

Commit 0fb5dd4

Browse files
authored
feat(webserver): introduce Attempt.data to store dynamic data
1 parent b1682df commit 0fb5dd4

File tree

5 files changed

+79
-5
lines changed

5 files changed

+79
-5
lines changed

frontend/src/types/AttemptRenderData.generated.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type RenderError =
1818
| UnknownElementError
1919
| UnknownAttributeError
2020
| DuplicateNameError
21+
| ReservedNameError
2122
| XMLSyntaxError
2223
/**
2324
* Collects render errors and provides a sorted iterator.
@@ -167,6 +168,19 @@ export interface DuplicateNameError {
167168
*/
168169
line: number | null
169170
}
171+
/**
172+
* Reserved input name.
173+
*/
174+
export interface ReservedNameError {
175+
template: string
176+
template_kwargs: TemplateKwargs
177+
kind: 'reserved_name'
178+
type: string
179+
/**
180+
* Original line number where the error occurred or None if unknown.
181+
*/
182+
line: number | null
183+
}
170184
/**
171185
* Syntax error while parsing the XML.
172186
*/

questionpy_sdk/webserver/controllers/attempt/controller.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class AttemptTemplateContext(TypedDict):
5151
import_map: dict[str, str]
5252
javascript_calls: list[JsModuleCall]
5353
stylesheet_urls: list[str]
54+
data: dict[str, JsonValue]
5455

5556

5657
@dataclass
@@ -160,6 +161,7 @@ async def _render_ui(
160161
"import_map": await self._get_import_map(),
161162
"javascript_calls": self._get_js_calls(attempt, display_options),
162163
"stylesheet_urls": self._get_stylesheet_urls(attempt),
164+
"data": self._get_dynamic_data(last_attempt_data),
163165
}
164166

165167
render_errors: SectionErrorMap = {}
@@ -175,6 +177,16 @@ async def _render_ui(
175177

176178
return template_context, render_errors
177179

180+
def _get_dynamic_data(self, last_attempt_data: dict[str, JsonValue] | None) -> dict[str, JsonValue]:
181+
if not last_attempt_data:
182+
return {}
183+
184+
data = last_attempt_data.get("data", {})
185+
if not isinstance(data, dict):
186+
return {}
187+
188+
return data
189+
178190
async def _get_import_map(self) -> dict[str, str]:
179191
worker: Worker
180192
async with self._worker_pool.get_worker(self._package_location, 0, None) as worker:

questionpy_sdk/webserver/controllers/attempt/errors.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
| UnknownElementError
4848
| UnknownAttributeError
4949
| DuplicateNameError
50+
| ReservedNameError
5051
| XMLSyntaxError,
5152
Field(discriminator="kind"),
5253
]
@@ -284,6 +285,19 @@ def __init__(self, element: etree._Element, name: str, other_element: etree._Ele
284285
)
285286

286287

288+
class ReservedNameError(RenderElementError):
289+
"""Reserved input name."""
290+
291+
kind: Literal["reserved_name"] = "reserved_name"
292+
293+
def __init__(self, element: etree._Element, name: str):
294+
super().__init__(
295+
element,
296+
"{element} cannot use the reserved name {name}.",
297+
{"name": name},
298+
)
299+
300+
287301
class XMLSyntaxError(BaseRenderError):
288302
"""Syntax error while parsing the XML."""
289303

questionpy_sdk/webserver/controllers/attempt/question_ui.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
InvalidContentError,
2525
PlaceholderReferenceError,
2626
RenderErrorCollection,
27+
ReservedNameError,
2728
UnknownAttributeError,
2829
UnknownElementError,
2930
XMLSyntaxError,
@@ -732,8 +733,13 @@ def _check_input_names(self) -> None:
732733
for current_element in _assert_element_list(
733734
self._xpath("(//xhtml:button | //xhtml:input | //xhtml:select | //xhtml:textarea)[@name]")
734735
):
735-
# Get name, type, and value of the current element.
736736
name = str(current_element.attrib["name"])
737+
738+
if name == "data":
739+
reserved_name_error = ReservedNameError(element=current_element, name=name)
740+
self.errors.insert(reserved_name_error)
741+
continue
742+
737743
current_type = current_element.get("type", "text")
738744
current_value = current_element.get("value", "on")
739745

@@ -748,13 +754,17 @@ def _check_input_names(self) -> None:
748754

749755
if current_type not in {"checkbox", "radio"}:
750756
# Duplicate names are not allowed for other elements.
751-
error = DuplicateNameError(element=current_element, name=name, other_element=other_element)
752-
self.errors.insert(error)
757+
duplicate_name_error = DuplicateNameError(
758+
element=current_element, name=name, other_element=other_element
759+
)
760+
self.errors.insert(duplicate_name_error)
753761
continue
754762

755763
# Check that the types match and the value is unique.
756764
if other_type != current_type or current_value in values:
757-
error = DuplicateNameError(element=current_element, name=name, other_element=other_element)
758-
self.errors.insert(error)
765+
duplicate_name_error = DuplicateNameError(
766+
element=current_element, name=name, other_element=other_element
767+
)
768+
self.errors.insert(duplicate_name_error)
759769
else:
760770
values.add(current_value)

questionpy_sdk/webserver/templates/attempt.html.jinja2

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@
104104
}
105105
}
106106
107+
// Add dynamic data.
108+
formData['data'] = attempt.data
109+
107110
return formData
108111
}
109112
@@ -175,6 +178,7 @@
175178
#specificFeedback;
176179
#rightAnswer;
177180
#roles;
181+
#data;
178182
#environment;
179183
180184
/**
@@ -188,6 +192,7 @@
188192
* @param {?Element} specificFeedbackElement
189193
* @param {?Element} rightAnswer
190194
* @param {string[]} roles
195+
* @param {Object.<string, any>} data
191196
*/
192197
constructor(
193198
readOnly,
@@ -200,6 +205,7 @@
200205
specificFeedbackElement,
201206
rightAnswer,
202207
roles,
208+
data,
203209
) {
204210
this.#readOnly = readOnly;
205211
this.#showGeneralFeedback = showGeneralFeedback;
@@ -211,6 +217,7 @@
211217
this.#specificFeedback = specificFeedbackElement;
212218
this.#rightAnswer = rightAnswer;
213219
this.#roles = roles;
220+
this.#data = data;
214221
this.#environment = new AttemptEnvironment("SDK", 0);
215222
}
216223
@@ -311,6 +318,22 @@
311318
return this.#roles;
312319
}
313320
321+
/**
322+
* Get the object used to store dynamic data.
323+
*
324+
* @note
325+
* This object will be serialized to JSON by using `JSON.stringify` and therefore follows the conversion
326+
* rules of this function. This also means that the keys of (nested) objects will be converted to
327+
* strings.
328+
*
329+
* The depth of the object should not exceed 16.
330+
*
331+
* @returns {Object.<string, any>}
332+
*/
333+
get data() {
334+
return this.#data;
335+
}
336+
314337
/**
315338
* Get information about the current environment.
316339
*
@@ -332,6 +355,7 @@
332355
document.getElementById("container-specific-feedback"),
333356
document.getElementById("container-right-answer"),
334357
[{{ display_options.roles|map('lower')|map('tojson')|join(',') }}],
358+
{{ data|tojson }},
335359
);
336360
337361
{% for javascript_call in javascript_calls %}

0 commit comments

Comments
 (0)