From 0920cbd45cef545f0fa5e47b379ec3c3ea0b789c Mon Sep 17 00:00:00 2001 From: Martin Gauk Date: Tue, 29 Oct 2024 16:35:07 +0100 Subject: [PATCH] feat: allow javascript in attempts and small demo --- examples/static-files/js/test.js | 14 ++- .../static_files_example/question_type.py | 13 ++- .../templates/formulation.xhtml.j2 | 2 + poetry.lock | 6 +- pyproject.toml | 2 +- questionpy/__init__.py | 2 + questionpy/_attempt.py | 53 ++++++++++- questionpy/_wrappers/_question.py | 1 + questionpy_sdk/webserver/attempt.py | 41 +++++++- .../webserver/question_ui/__init__.py | 9 +- questionpy_sdk/webserver/routes/attempt.py | 3 +- questionpy_sdk/webserver/static/attempt.js | 93 +++++++++++++++++++ .../webserver/templates/attempt.html.jinja2 | 30 ++++++ 13 files changed, 251 insertions(+), 18 deletions(-) create mode 100644 questionpy_sdk/webserver/static/attempt.js diff --git a/examples/static-files/js/test.js b/examples/static-files/js/test.js index 940a3ff0..d6871f59 100644 --- a/examples/static-files/js/test.js +++ b/examples/static-files/js/test.js @@ -1 +1,13 @@ -console.log("Hello world!"); +define(function () { + return { + initButton: function (attempt) { + attempt.getElementById("mybutton").addEventListener("click", function (event) { + event.target.disabled = true; + attempt.getElementById("hiddenInput").value = "secret"; + }) + }, + hello: function (attempt, param) { + console.log("hello " + param); + }, + } +}); diff --git a/examples/static-files/python/local/static_files_example/question_type.py b/examples/static-files/python/local/static_files_example/question_type.py index b8c40c36..d11661ca 100644 --- a/examples/static-files/python/local/static_files_example/question_type.py +++ b/examples/static-files/python/local/static_files_example/question_type.py @@ -1,10 +1,21 @@ -from questionpy import Attempt, Question +from questionpy import Attempt, JsModuleCallRoleFeedback, Question, ResponseNotScorableError from .form import MyModel class ExampleAttempt(Attempt): + def _init_attempt(self) -> None: + self.call_js("@local/static_files_example/test", "initButton") + self.call_js("@local/static_files_example/test", "hello", "world", JsModuleCallRoleFeedback.GENERAL_FEEDBACK) + def _compute_score(self) -> float: + if not self.response or "hidden_value" not in self.response: + msg = "'hidden_value' is missing" + raise ResponseNotScorableError(msg) + + if self.response["hidden_value"] == "secret": + return 1 + return 0 @property diff --git a/examples/static-files/python/local/static_files_example/templates/formulation.xhtml.j2 b/examples/static-files/python/local/static_files_example/templates/formulation.xhtml.j2 index e22d5125..bd5bd5ed 100644 --- a/examples/static-files/python/local/static_files_example/templates/formulation.xhtml.j2 +++ b/examples/static-files/python/local/static_files_example/templates/formulation.xhtml.j2 @@ -1,4 +1,6 @@
I have custom styling!
+ +
diff --git a/poetry.lock b/poetry.lock index 11d28631..241dbda6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1409,8 +1409,8 @@ watchdog = "^4.0.0" [package.source] type = "git" url = "https://github.com/questionpy-org/questionpy-server.git" -reference = "8635a2ed685dbffce4564562a79effaba1751873" -resolved_reference = "8635a2ed685dbffce4564562a79effaba1751873" +reference = "0164ff9f77eae961d77b15be316e9c674e3e1673" +resolved_reference = "0164ff9f77eae961d77b15be316e9c674e3e1673" [[package]] name = "ruff" @@ -1770,4 +1770,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "731359c6c18884531635bae03f1483eda8e129da000108d91a36f3c1fc18d954" +content-hash = "609512cfce4127c795e41adbe79133d43213d4550edd6d49b871d528ac2aeaa1" diff --git a/pyproject.toml b/pyproject.toml index 284583cc..93424837 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ python = "^3.11" aiohttp = "^3.9.3" pydantic = "^2.6.4" PyYAML = "^6.0.1" -questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "8635a2ed685dbffce4564562a79effaba1751873" } +questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "0164ff9f77eae961d77b15be316e9c674e3e1673" } jinja2 = "^3.1.3" aiohttp-jinja2 = "^1.6" lxml = "~5.1.0" diff --git a/questionpy/__init__.py b/questionpy/__init__.py index c7c9b878..0e4a99e1 100644 --- a/questionpy/__init__.py +++ b/questionpy/__init__.py @@ -11,6 +11,7 @@ AttemptUi, CacheControl, ClassifiedResponse, + JsModuleCallRoleFeedback, ScoreModel, ScoringCode, ) @@ -63,6 +64,7 @@ "ClassifiedResponse", "Environment", "InvalidResponseError", + "JsModuleCallRoleFeedback", "Manifest", "NeedsManualScoringError", "NoEnvironmentError", diff --git a/questionpy/_attempt.py b/questionpy/_attempt.py index 1ec3e20e..8f87939a 100644 --- a/questionpy/_attempt.py +++ b/questionpy/_attempt.py @@ -1,12 +1,21 @@ +import json from abc import ABC, abstractmethod -from collections.abc import Mapping, Sequence +from collections.abc import Iterable, Mapping, Sequence from functools import cached_property from typing import TYPE_CHECKING, ClassVar, Protocol import jinja2 from pydantic import BaseModel, JsonValue -from questionpy_common.api.attempt import AttemptFile, AttemptUi, CacheControl, ScoredInputModel, ScoringCode +from questionpy_common.api.attempt import ( + AttemptFile, + AttemptUi, + CacheControl, + JsModuleCall, + JsModuleCallRoleFeedback, + ScoredInputModel, + ScoringCode, +) from ._ui import create_jinja2_environment from ._util import get_mro_type_hint @@ -75,6 +84,10 @@ def placeholders(self) -> dict[str, str]: def css_files(self) -> list[str]: pass + @property + def javascript_calls(self) -> Iterable[JsModuleCall]: + pass + @property def files(self) -> dict[str, AttemptFile]: pass @@ -156,6 +169,10 @@ def __init__( self.cache_control = CacheControl.PRIVATE_CACHE self.placeholders: dict[str, str] = {} self.css_files: list[str] = [] + self._javascript_calls: dict[JsModuleCall, None] = {} + """LMS has to call these JS modules/functions. A dict is used as a set to avoid duplicates and to preserve + the insertion order.""" + self.files: dict[str, AttemptFile] = {} self.scoring_code: ScoringCode | None = None @@ -187,6 +204,11 @@ def __init__( only be viewed as an output. """ + self._init_attempt() + + def _init_attempt(self) -> None: # noqa: B027 + """A place for the question to initialize the attempt (set up fields, JavaScript calls, etc.).""" + @property @abstractmethod def formulation(self) -> str: @@ -243,6 +265,33 @@ def jinja2(self) -> jinja2.Environment: def variant(self) -> int: return self.attempt_state.variant + def call_js( + self, + module: str, + function: str, + data: JsonValue = None, + if_role_feedback: JsModuleCallRoleFeedback | None = None, + ) -> None: + """Call a javascript function when the LMS displays this question attempt. + + Args: + module: JS module name specified as: + @[package namespace]/[package short name]/[subdir]/[module name] (full reference) or + TODO [subdir]/[module name] (referencing a module within the package where this class is subclassed) or + TODO attempt/[module name] (referencing a dynamically created module returned as an attempt file) + function: Name of a callable value within the JS module + data: arbitrary data to pass to the function + if_role_feedback: Function is only called if the user has this role or is allowed to view this + feedback type. If None, the function is always called. + """ + data_json = "" if data is None else json.dumps(data) + call = JsModuleCall(module=module, function=function, data=data_json, if_role_feedback=if_role_feedback) + self._javascript_calls[call] = None + + @property + def javascript_calls(self) -> Iterable[JsModuleCall]: + return self._javascript_calls.keys() + def __init_subclass__(cls, *args: object, **kwargs: object): super().__init_subclass__(*args, **kwargs) diff --git a/questionpy/_wrappers/_question.py b/questionpy/_wrappers/_question.py index 190cc10e..56ee64b8 100644 --- a/questionpy/_wrappers/_question.py +++ b/questionpy/_wrappers/_question.py @@ -55,6 +55,7 @@ def _export_attempt(attempt: AttemptProtocol) -> dict: right_answer=attempt.right_answer_description, placeholders=attempt.placeholders, css_files=attempt.css_files, + javascript_calls=attempt.javascript_calls, files=attempt.files, cache_control=attempt.cache_control, ), diff --git a/questionpy_sdk/webserver/attempt.py b/questionpy_sdk/webserver/attempt.py index e550de86..077df99d 100644 --- a/questionpy_sdk/webserver/attempt.py +++ b/questionpy_sdk/webserver/attempt.py @@ -3,7 +3,14 @@ # (c) Technische Universität Berlin, innoCampus from typing import Literal, TypedDict -from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel +from questionpy_common.api.attempt import ( + AttemptModel, + AttemptScoredModel, + AttemptStartedModel, + JsModuleCall, + JsModuleCallRoleFeedback, +) +from questionpy_common.manifest import Manifest from questionpy_sdk.webserver.question_ui import ( QuestionDisplayOptions, QuestionFormulationUIRenderer, @@ -15,6 +22,7 @@ class _AttemptRenderContext(TypedDict): attempt_status: Literal["Started", "In progress", "Scored"] + manifest: Manifest attempt: AttemptModel attempt_state: str @@ -26,10 +34,39 @@ class _AttemptRenderContext(TypedDict): specific_feedback: str | None right_answer: str | None + javascript_calls: list[JsModuleCall] + render_errors: RenderErrorCollections +def filter_js_calls_by_role_feedback( + calls: list[JsModuleCall], display_options: QuestionDisplayOptions +) -> list[JsModuleCall]: + def role_feedback_allowed(call: JsModuleCall) -> bool: + match call.if_role_feedback: + case None: + return True + case ( + JsModuleCallRoleFeedback.TEACHER + | JsModuleCallRoleFeedback.DEVELOPER + | JsModuleCallRoleFeedback.SCORER + | JsModuleCallRoleFeedback.PROCTOR + ): + return call.if_role_feedback in display_options.roles + case JsModuleCallRoleFeedback.GENERAL_FEEDBACK: + return display_options.general_feedback + case JsModuleCallRoleFeedback.SPECIFIC_FEEDBACK: + return display_options.specific_feedback + case JsModuleCallRoleFeedback.RIGHT_ANSWER: + return display_options.right_answer + + return False + + return list(filter(role_feedback_allowed, calls)) + + def get_attempt_render_context( + manifest: Manifest, attempt: AttemptModel, attempt_state: str, *, @@ -55,6 +92,8 @@ def get_attempt_render_context( "form_disabled": disabled, "formulation": html, "attempt": attempt, + "manifest": manifest, + "javascript_calls": filter_js_calls_by_role_feedback(attempt.ui.javascript_calls, display_options), "general_feedback": None, "specific_feedback": None, "right_answer": None, diff --git a/questionpy_sdk/webserver/question_ui/__init__.py b/questionpy_sdk/webserver/question_ui/__init__.py index f764461e..46dba356 100644 --- a/questionpy_sdk/webserver/question_ui/__init__.py +++ b/questionpy_sdk/webserver/question_ui/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import re -from enum import StrEnum from random import Random from typing import Any @@ -13,6 +12,7 @@ from lxml import etree from pydantic import BaseModel +from questionpy_common.api.attempt import QuestionDisplayRole from questionpy_sdk.webserver.question_ui.errors import ( ConversionError, InvalidAttributeValueError, @@ -165,13 +165,6 @@ def __init__(self) -> None: self.required_fields: list[str] = [] -class QuestionDisplayRole(StrEnum): - DEVELOPER = "DEVELOPER" - PROCTOR = "PROCTOR" - SCORER = "SCORER" - TEACHER = "TEACHER" - - class QuestionDisplayOptions(BaseModel): general_feedback: bool = True specific_feedback: bool = True diff --git a/questionpy_sdk/webserver/routes/attempt.py b/questionpy_sdk/webserver/routes/attempt.py index 7c5987fc..8e50bb3d 100644 --- a/questionpy_sdk/webserver/routes/attempt.py +++ b/questionpy_sdk/webserver/routes/attempt.py @@ -12,7 +12,7 @@ from questionpy_common.api.attempt import AttemptScoredModel, ScoreModel from questionpy_common.environment import RequestUser -from questionpy_sdk.webserver.app import SDK_WEBSERVER_APP_KEY, StateFilename +from questionpy_sdk.webserver.app import MANIFEST_APP_KEY, SDK_WEBSERVER_APP_KEY, StateFilename from questionpy_sdk.webserver.attempt import get_attempt_render_context from questionpy_sdk.webserver.question_ui import QuestionDisplayOptions @@ -83,6 +83,7 @@ async def get_attempt(request: web.Request) -> web.Response: display_options.general_feedback = display_options.specific_feedback = display_options.right_answer = False context = get_attempt_render_context( + request.app[MANIFEST_APP_KEY], attempt, attempt_state, last_attempt_data=last_attempt_data, diff --git a/questionpy_sdk/webserver/static/attempt.js b/questionpy_sdk/webserver/static/attempt.js new file mode 100644 index 00000000..d69a7af7 --- /dev/null +++ b/questionpy_sdk/webserver/static/attempt.js @@ -0,0 +1,93 @@ +/* + * This file is part of the QuestionPy SDK. (https://questionpy.org) + * The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. + * (c) Technische Universität Berlin, innoCampus + */ + +/** + * Unescape and replace HTML entities. + * + * @param {string} input + * @returns {string} + */ +function htmlDecode(input) { + var txt = document.createElement("textarea"); + txt.innerHTML = input; + return txt.value; +} + +class Attempt { + #formulation; + #generalFeedback; + #specificFeedback; + #rightAnswer; + + /** + * @param {Element} formulationElement + * @param {Element} generalFeedbackElement + * @param {Element} specificFeedbackElement + * @param {Element} rightAnswer + */ + constructor(formulationElement, generalFeedbackElement, specificFeedbackElement, rightAnswer) { + this.#formulation = formulationElement; + this.#generalFeedback = generalFeedbackElement; + this.#specificFeedback = specificFeedbackElement; + this.#rightAnswer = rightAnswer; + } + + /** + * Get the top html element where the question's formulation xhtml was inserted. + * + * @returns {Element} + */ + get formulationElement() { + return this.#formulation; + } + + /** + * Get the top html element where the question's general feedback xhtml was inserted. + * + * @returns {Element} + */ + get generalFeedbackElement() { + return this.#generalFeedback; + } + + /** + * Get the top html element where the question's specific feedback xhtml was inserted. + * + * @returns {Element} + */ + get specificFeedbackElement() { + return this.#specificFeedback; + } + + /** + * Get the top html element where the question's right answer xhtml was inserted. + * + * @returns {Element} + */ + get rightAnswerElement() { + return this.#rightAnswer; + } + + /** + * Get an element by the id that was given in the question's xhtml. + * + * @param {string} id + * @return {?Element} + */ + getElementById(id) { + return document.getElementById(id); + } + + /** + * Get an element by the name that was given in the question's xhtml. + * + * @param {string} name + * @return {NodeListOf} + */ + getElementsByName(name) { + return document.getElementsByName(name); + } +} diff --git a/questionpy_sdk/webserver/templates/attempt.html.jinja2 b/questionpy_sdk/webserver/templates/attempt.html.jinja2 index dbff95df..2b4c30f5 100644 --- a/questionpy_sdk/webserver/templates/attempt.html.jinja2 +++ b/questionpy_sdk/webserver/templates/attempt.html.jinja2 @@ -4,6 +4,10 @@ {% block head %} {{ super() }} + + {% endblock %} {% block title %}Question Preview{% endblock %} {% block header %} @@ -151,4 +155,30 @@ {# TODO: Role selection (admin, teacher, student, ...) #} {# TODO: Locale selection #} + {% endblock %}