Skip to content

Commit f5b8ea4

Browse files
committed
feat(webserver): call custom javascript
1 parent 667d4f5 commit f5b8ea4

File tree

8 files changed

+309
-13
lines changed

8 files changed

+309
-13
lines changed

questionpy_sdk/webserver/controllers/attempt/controller.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# (c) Technische Universität Berlin, innoCampus <[email protected]>
44

55
import random
6+
import re
67
from enum import StrEnum
78
from typing import TYPE_CHECKING, Any, Literal, TypedDict, overload
89

@@ -11,7 +12,7 @@
1112
from pydantic.dataclasses import dataclass
1213

1314
from questionpy import AttemptModel, AttemptScoredModel, AttemptStartedModel, ScoreModel
14-
from questionpy_common.api.attempt import ScoringCode
15+
from questionpy_common.api.attempt import FeedbackType, JsModuleCall, ScoringCode
1516
from questionpy_sdk.webserver.constants import DEFAULT_REQUEST_USER
1617
from questionpy_sdk.webserver.controllers.base import BaseController
1718
from questionpy_sdk.webserver.controllers.errors import MissingAttemptDataError, MissingAttemptStateError
@@ -42,6 +43,9 @@ class AttemptTemplateContext(TypedDict):
4243
general_feedback: str | None
4344
specific_feedback: str | None
4445
right_answer: str | None
46+
display_options: QuestionDisplayOptions
47+
import_map: dict[str, str]
48+
javascript_calls: list[JsModuleCall]
4549

4650

4751
@dataclass
@@ -133,14 +137,23 @@ async def _render_ui(
133137
display_options.right_answer = display_options.correctness = False
134138

135139
# Render UI
136-
renderer_args = (attempt.ui.placeholders, display_options, await self._get_attempt_seed(), last_attempt_data)
140+
renderer_args = (
141+
attempt.ui.placeholders,
142+
display_options,
143+
self.qpy_url_replacer,
144+
await self._get_attempt_seed(),
145+
last_attempt_data,
146+
)
137147
html, errors = QuestionFormulationUIRenderer(attempt.ui.formulation, *renderer_args).render()
138148

139149
template_context: AttemptTemplateContext = {
140150
"formulation": html,
141151
"general_feedback": None,
142152
"specific_feedback": None,
143153
"right_answer": None,
154+
"display_options": display_options,
155+
"import_map": await self._get_import_map(),
156+
"javascript_calls": self._get_js_calls(attempt, display_options),
144157
}
145158

146159
render_errors: SectionErrorMap = {}
@@ -156,6 +169,36 @@ async def _render_ui(
156169

157170
return template_context, render_errors
158171

172+
async def _get_import_map(self) -> dict[str, str]:
173+
worker: Worker
174+
async with self._worker_pool.get_worker(self._package_location, 0, None) as worker:
175+
dependencies = worker.get_loaded_packages(only_with_hash=False)
176+
177+
return {
178+
f"@{dependency.namespace}/{dependency.short_name}/":
179+
str(self.generate_api_url(
180+
"file",
181+
namespace=dependency.namespace,
182+
short_name=dependency.short_name,
183+
path="static/js/",
184+
))
185+
for dependency in dependencies
186+
} # fmt: skip
187+
188+
def _get_js_calls(self, attempt: AttemptModel, display_options: QuestionDisplayOptions) -> list[JsModuleCall]:
189+
feedback_map = {
190+
FeedbackType.GENERAL_FEEDBACK: display_options.general_feedback,
191+
FeedbackType.SPECIFIC_FEEDBACK: display_options.specific_feedback,
192+
FeedbackType.RIGHT_ANSWER: display_options.right_answer,
193+
}
194+
195+
return [
196+
call
197+
for call in attempt.ui.javascript_calls
198+
if (call.if_role is None or call.if_role in display_options.roles)
199+
and (call.if_feedback_type is None or feedback_map[call.if_feedback_type])
200+
]
201+
159202
@property
160203
def _attempt_template(self) -> jinja2.Template:
161204
loader = jinja2.PackageLoader("questionpy_sdk.webserver")
@@ -225,3 +268,13 @@ async def _get_last_attempt_data(self, *, allow_missing: bool = False) -> dict[s
225268
if allow_missing:
226269
return None
227270
raise MissingAttemptDataError from err
271+
272+
def qpy_url_replacer(self, match: re.Match[str]) -> str:
273+
return str(
274+
self.generate_api_url(
275+
"file",
276+
namespace=match.group(2),
277+
short_name=match.group(3),
278+
path=match.group(1),
279+
)
280+
)

questionpy_sdk/webserver/controllers/attempt/question_ui.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import re
88
from random import Random
9-
from typing import Any
9+
from typing import TYPE_CHECKING, Any
1010

1111
import lxml.html
1212
import lxml.html.clean
@@ -29,6 +29,9 @@
2929
XMLSyntaxError,
3030
)
3131

32+
if TYPE_CHECKING:
33+
from collections.abc import Callable
34+
3235
_XHTML_NAMESPACE: str = "http://www.w3.org/1999/xhtml"
3336
_QPY_NAMESPACE: str = "http://questionpy.org/ns/question"
3437

@@ -208,10 +211,12 @@ def __init__(
208211
xml: str,
209212
placeholders: dict[str, str],
210213
options: QuestionDisplayOptions,
214+
qpy_url_replacer: Callable[[re.Match[str]], str],
211215
seed: int | None = None,
212216
attempt: dict | None = None,
213217
) -> None:
214218
self._html: str | None = None
219+
self._qpy_url_replacer = qpy_url_replacer
215220

216221
xml = self._replace_qpy_urls(xml)
217222
self._error_collector = _RenderErrorCollector(xml, placeholders)
@@ -265,7 +270,11 @@ def render(self) -> tuple[str, RenderErrorCollection]:
265270

266271
def _replace_qpy_urls(self, xml: str) -> str:
267272
"""Replace QPY-URLs to package files with SDK-URLs."""
268-
return re.sub(r"qpy://(static|static-private)/((?:[a-z_][a-z0-9_]{0,126}/){2})", r"/worker/\2file/\1/", xml)
273+
return re.sub(
274+
r"qpy://((?:static|static-private)/)([a-z_]\w{0,126})/([a-z_]\w{0,126})/",
275+
self._qpy_url_replacer,
276+
xml,
277+
)
269278

270279
def _resolve_placeholders(self) -> None:
271280
"""Replace placeholder PIs such as `<?p my_key plain?>` with the appropriate value from `self.placeholders`.
@@ -492,10 +501,11 @@ def __init__(
492501
xml: str,
493502
placeholders: dict[str, str],
494503
options: QuestionDisplayOptions,
504+
qpy_url_replacer: Callable[[re.Match[str]], str],
495505
seed: int | None = None,
496506
attempt: dict | None = None,
497507
) -> None:
498-
super().__init__(xml, placeholders, options, seed, attempt)
508+
super().__init__(xml, placeholders, options, qpy_url_replacer, seed, attempt)
499509
self.metadata = self._get_metadata()
500510

501511
def _get_metadata(self) -> QuestionMetadata:
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# This file is part of the QuestionPy SDK. (https://questionpy.org)
2+
# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md.
3+
# (c) Technische Universität Berlin, innoCampus <[email protected]>
4+
from typing import TYPE_CHECKING
5+
6+
from questionpy_sdk.webserver.controllers.base import BaseController
7+
from questionpy_server.worker import PackageFileData
8+
9+
if TYPE_CHECKING:
10+
from questionpy_server.worker import Worker
11+
12+
13+
class FileController(BaseController):
14+
async def get_static_file(self, namespace: str, short_name: str, path: str) -> PackageFileData:
15+
if self._manifest.namespace != namespace or self._manifest.short_name != short_name:
16+
# TODO: Support static files in non-main packages by using namespace and short_name.
17+
msg = "Static file retrieval from non-main packages is not supported yet."
18+
raise ValueError(msg)
19+
20+
worker: Worker
21+
async with self._worker_pool.get_worker(self._package_location, 0, None) as worker:
22+
return await worker.get_static_file(path)

questionpy_sdk/webserver/routes/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
# (c) Technische Universität Berlin, innoCampus <[email protected]>
44

55
from .attempt import routes as attempt_routes
6+
from .file import routes as file_routes
67
from .manifest import routes as manifest_routes
78
from .options import routes as options_routes
89

9-
api_routes = (attempt_routes, manifest_routes, options_routes)
10+
api_routes = (attempt_routes, manifest_routes, options_routes, file_routes)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# This file is part of the QuestionPy SDK. (https://questionpy.org)
2+
# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md.
3+
# (c) Technische Universität Berlin, innoCampus <[email protected]>
4+
5+
from aiohttp import web
6+
from aiohttp.web_exceptions import HTTPNotImplemented
7+
8+
from questionpy_sdk.webserver.controllers.file import FileController
9+
from questionpy_sdk.webserver.routes.base import BaseView
10+
11+
routes = web.RouteTableDef()
12+
13+
14+
@routes.view(r"/file/{namespace}/{short_name}/{path:(static|static-private)/.*}", name="file")
15+
class FileView(BaseView["FileController"]):
16+
controller_class = FileController
17+
18+
async def get(self) -> web.Response:
19+
"""Gets the file from the specified package at the given path."""
20+
namespace = self.request.match_info["namespace"]
21+
short_name = self.request.match_info["short_name"]
22+
path = self.request.match_info["path"]
23+
24+
try:
25+
file = await self.controller.get_static_file(namespace, short_name, path)
26+
except FileNotFoundError as e:
27+
raise web.HTTPNotFound(text="File not found.") from e
28+
except ValueError as e:
29+
raise HTTPNotImplemented(text=str(e)) from e
30+
31+
return web.Response(
32+
body=file.data,
33+
content_type=file.mime_type,
34+
headers={"Cache-Control": "no-store"},
35+
)

0 commit comments

Comments
 (0)