Skip to content

Commit 5b0a2ca

Browse files
committed
feat: allow javascript in attempts and simple demo
1 parent e38d230 commit 5b0a2ca

File tree

12 files changed

+211
-67
lines changed

12 files changed

+211
-67
lines changed

examples/static-files/js/test.js

+18-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,18 @@
1-
console.log("Hello world!");
1+
import { returnTest2 } from './test2.js';
2+
3+
export function initButton(attempt, [buttonId, inputId, secretValue]) {
4+
console.log("called initButton");
5+
6+
if (returnTest2() !== "test2") {
7+
console.error("method did not return 'test2'");
8+
}
9+
10+
document.getElementById(buttonId).addEventListener("click", function (event) {
11+
event.target.disabled = true;
12+
document.getElementById(inputId).value = secretValue;
13+
})
14+
}
15+
16+
export function hello(attempt, param) {
17+
console.log("hello " + param);
18+
}

examples/static-files/js/test2.js

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function returnTest2() {
2+
return 'test2';
3+
}

examples/static-files/python/local/static_files_example/question_type.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
1-
from questionpy import Attempt, Question
1+
from questionpy import Attempt, FeedbackType, Question, ResponseNotScorableError
22

33
from .form import MyModel
44

55

66
class ExampleAttempt(Attempt):
7+
def _init_attempt(self) -> None:
8+
self.call_js("@local/static_files_example/test.js", "initButton", ["mybutton", "hiddenInput", "secret"])
9+
self.call_js(
10+
"@local/static_files_example/test.js", "hello", "world", if_feedback_type=FeedbackType.GENERAL_FEEDBACK
11+
)
12+
713
def _compute_score(self) -> float:
14+
if not self.response or "hidden_value" not in self.response:
15+
msg = "'hidden_value' is missing"
16+
raise ResponseNotScorableError(msg)
17+
18+
if self.response["hidden_value"] == "secret":
19+
return 1
20+
821
return 0
922

1023
@property
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<div xmlns="http://www.w3.org/1999/xhtml"
22
xmlns:qpy="http://questionpy.org/ns/question">
33
<div class="my-custom-class">I have custom styling!</div>
4+
<input type="hidden" name="hidden_value" id="hiddenInput" />
5+
<input type="button" id="mybutton" value="click here for a 1.0 score" />
46
</div>

poetry.lock

+101-45
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ python = "^3.11"
2828
aiohttp = "^3.9.3"
2929
pydantic = "^2.6.4"
3030
PyYAML = "^6.0.1"
31-
questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "aca709b1dd8cef935ac4bf4bb47471959aac6779" }
31+
questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "3f8f2e6199d2c718aef79b07e3fcd5234282a2b3" }
3232
jinja2 = "^3.1.3"
3333
aiohttp-jinja2 = "^1.6"
3434
lxml = "~5.1.0"

questionpy/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
AttemptUi,
1212
CacheControl,
1313
ClassifiedResponse,
14+
DisplayRole,
15+
FeedbackType,
1416
ScoreModel,
1517
ScoringCode,
1618
)
@@ -61,7 +63,9 @@
6163
"BaseScoringState",
6264
"CacheControl",
6365
"ClassifiedResponse",
66+
"DisplayRole",
6467
"Environment",
68+
"FeedbackType",
6569
"InvalidResponseError",
6670
"Manifest",
6771
"NeedsManualScoringError",

questionpy/_attempt.py

+55-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from abc import ABC, abstractmethod
23
from collections.abc import Mapping, Sequence
34
from functools import cached_property
@@ -6,7 +7,16 @@
67
import jinja2
78
from pydantic import BaseModel, JsonValue
89

9-
from questionpy_common.api.attempt import AttemptFile, AttemptUi, CacheControl, ScoredInputModel, ScoringCode
10+
from questionpy_common.api.attempt import (
11+
AttemptFile,
12+
AttemptUi,
13+
CacheControl,
14+
DisplayRole,
15+
FeedbackType,
16+
JsModuleCall,
17+
ScoredInputModel,
18+
ScoringCode,
19+
)
1020

1121
from ._ui import create_jinja2_environment
1222
from ._util import reify_type_hint
@@ -75,6 +85,10 @@ def placeholders(self) -> dict[str, str]:
7585
def css_files(self) -> list[str]:
7686
pass
7787

88+
@property
89+
def javascript_calls(self) -> list[JsModuleCall]:
90+
pass
91+
7892
@property
7993
def files(self) -> dict[str, AttemptFile]:
8094
pass
@@ -156,6 +170,9 @@ def __init__(
156170
self.cache_control = CacheControl.PRIVATE_CACHE
157171
self.placeholders: dict[str, str] = {}
158172
self.css_files: list[str] = []
173+
self._javascript_calls: list[JsModuleCall] = []
174+
"""LMS has to call these JS modules/functions."""
175+
159176
self.files: dict[str, AttemptFile] = {}
160177

161178
self.scoring_code: ScoringCode | None = None
@@ -187,6 +204,11 @@ def __init__(
187204
only be viewed as an output.
188205
"""
189206

207+
self._init_attempt()
208+
209+
def _init_attempt(self) -> None: # noqa: B027
210+
"""A place for the question to initialize the attempt (set up fields, JavaScript calls, etc.)."""
211+
190212
@property
191213
@abstractmethod
192214
def formulation(self) -> str:
@@ -243,6 +265,38 @@ def jinja2(self) -> jinja2.Environment:
243265
def variant(self) -> int:
244266
return self.attempt_state.variant
245267

268+
def call_js(
269+
self,
270+
module: str,
271+
function: str,
272+
data: JsonValue = None,
273+
*,
274+
if_role: DisplayRole | None = None,
275+
if_feedback_type: FeedbackType | None = None,
276+
) -> None:
277+
"""Call a javascript function when the LMS displays this question attempt.
278+
279+
The function is called when both the `if_role` and `if_feedback_type` conditions are met.
280+
281+
Args:
282+
module: JS module name specified as:
283+
@[package namespace]/[package short name]/[subdir]/[module name] (full reference) or
284+
TODO [subdir]/[module name] (referencing a module within the package where this class is subclassed) or
285+
function: Name of a callable value within the JS module
286+
data: arbitrary data to pass to the function
287+
if_role: Function is only called if the user has this role.
288+
if_feedback_type: Function is only called if the user is allowed to view this feedback type.
289+
"""
290+
data_json = None if data is None else json.dumps(data)
291+
call = JsModuleCall(
292+
module=module, function=function, data=data_json, if_role=if_role, if_feedback_type=if_feedback_type
293+
)
294+
self._javascript_calls.append(call)
295+
296+
@property
297+
def javascript_calls(self) -> list[JsModuleCall]:
298+
return self._javascript_calls
299+
246300
def __init_subclass__(cls, *args: object, **kwargs: object):
247301
super().__init_subclass__(*args, **kwargs)
248302

questionpy/_wrappers/_question.py

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def _export_attempt(attempt: AttemptProtocol) -> dict:
5555
right_answer=attempt.right_answer_description,
5656
placeholders=attempt.placeholders,
5757
css_files=attempt.css_files,
58+
javascript_calls=attempt.javascript_calls,
5859
files=attempt.files,
5960
cache_control=attempt.cache_control,
6061
),

questionpy_sdk/commands/_helper.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from questionpy_sdk.package.builder import DirPackageBuilder
1313
from questionpy_sdk.package.errors import PackageBuildError, PackageSourceValidationError
1414
from questionpy_sdk.package.source import PackageSource
15+
from questionpy_server.hash import calculate_hash
1516
from questionpy_server.worker.runtime.package_location import (
1617
DirPackageLocation,
1718
PackageLocation,
@@ -53,7 +54,7 @@ def get_package_location(pkg_string: str, pkg_path: Path) -> PackageLocation:
5354
return _get_dir_package_location_from_source(pkg_string, pkg_path)
5455

5556
if zipfile.is_zipfile(pkg_path):
56-
return ZipPackageLocation(pkg_path)
57+
return ZipPackageLocation(pkg_path, calculate_hash(pkg_path))
5758

5859
msg = f"'{pkg_string}' doesn't look like a QPy package file, source directory, or dist directory."
5960
raise click.ClickException(msg)

questionpy_sdk/webserver/question_ui/__init__.py

+8-15
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from __future__ import annotations
55

66
import re
7-
from enum import StrEnum
87
from random import Random
98
from typing import Any
109

@@ -13,6 +12,7 @@
1312
from lxml import etree
1413
from pydantic import BaseModel
1514

15+
from questionpy_common.api.attempt import DisplayRole
1616
from questionpy_sdk.webserver.question_ui.errors import (
1717
ConversionError,
1818
ExpectedAncestorError,
@@ -185,22 +185,15 @@ def __init__(self) -> None:
185185
self.required_fields: list[str] = []
186186

187187

188-
class QuestionDisplayRole(StrEnum):
189-
DEVELOPER = "DEVELOPER"
190-
PROCTOR = "PROCTOR"
191-
SCORER = "SCORER"
192-
TEACHER = "TEACHER"
193-
194-
195188
class QuestionDisplayOptions(BaseModel):
196189
general_feedback: bool = True
197190
specific_feedback: bool = True
198191
right_answer: bool = True
199-
roles: set[QuestionDisplayRole] = {
200-
QuestionDisplayRole.DEVELOPER,
201-
QuestionDisplayRole.PROCTOR,
202-
QuestionDisplayRole.SCORER,
203-
QuestionDisplayRole.TEACHER,
192+
roles: set[DisplayRole] = {
193+
DisplayRole.DEVELOPER,
194+
DisplayRole.PROCTOR,
195+
DisplayRole.SCORER,
196+
DisplayRole.TEACHER,
204197
}
205198
readonly: bool = False
206199

@@ -330,7 +323,7 @@ def _hide_if_role(self) -> None:
330323
for element in _assert_element_list(self._xpath("//*[@qpy:if-role]")):
331324
if attr := element.get(f"{{{_QPY_NAMESPACE}}}if-role"):
332325
allowed_roles = [role.upper() for role in re.split(r"[\s|]+", attr)]
333-
has_role = any(role in allowed_roles and role in self._options.roles for role in QuestionDisplayRole)
326+
has_role = any(role in allowed_roles and role in self._options.roles for role in DisplayRole)
334327

335328
if not has_role and (parent := element.getparent()) is not None:
336329
parent.remove(element)
@@ -648,7 +641,7 @@ def _validate_if_role(self) -> None:
648641
for element in _assert_element_list(self._xpath("//*[@qpy:if-role]")):
649642
if attr := element.get(f"{{{_QPY_NAMESPACE}}}if-role"):
650643
allowed_roles = [role.upper() for role in re.split(r"[\s|]+", attr)]
651-
expected = list(QuestionDisplayRole)
644+
expected = list(DisplayRole)
652645
if unexpected := [role for role in allowed_roles if role not in expected]:
653646
error = InvalidAttributeValueError(
654647
element=element,

tests/questionpy_sdk/webserver/test_question_ui.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
import pytest
88

99
from questionpy_sdk.webserver.question_ui import (
10+
DisplayRole,
1011
QuestionDisplayOptions,
11-
QuestionDisplayRole,
1212
QuestionFormulationUIRenderer,
1313
QuestionMetadata,
1414
QuestionUIRenderer,
@@ -146,7 +146,7 @@ def test_should_show_inline_feedback(renderer: QuestionUIRenderer) -> None:
146146
"<div></div>",
147147
),
148148
(
149-
QuestionDisplayOptions(roles={QuestionDisplayRole.SCORER}),
149+
QuestionDisplayOptions(roles={DisplayRole.SCORER}),
150150
"""
151151
<div>
152152
<div>You're a scorer!</div>

0 commit comments

Comments
 (0)