From b77b3e92f6d199b4ec4aaf37c20ea4abed5df335 Mon Sep 17 00:00:00 2001 From: Martin Gauk Date: Thu, 16 Jan 2025 18:09:38 +0100 Subject: [PATCH] feat: allow javascript in attempts and simple demo --- examples/static-files/js/test.js | 19 ++++++- examples/static-files/js/test2.js | 3 + .../static_files_example/question_type.py | 13 ++++- .../templates/formulation.xhtml.j2 | 2 + poetry.lock | 12 ++-- pyproject.toml | 2 +- questionpy/__init__.py | 4 ++ questionpy/_attempt.py | 56 ++++++++++++++++++- questionpy/_wrappers/_question.py | 1 + questionpy_sdk/commands/_helper.py | 3 +- .../webserver/question_ui/__init__.py | 23 +++----- .../webserver/test_question_ui.py | 4 +- 12 files changed, 114 insertions(+), 28 deletions(-) create mode 100644 examples/static-files/js/test2.js diff --git a/examples/static-files/js/test.js b/examples/static-files/js/test.js index 940a3ff0..ec2475e0 100644 --- a/examples/static-files/js/test.js +++ b/examples/static-files/js/test.js @@ -1 +1,18 @@ -console.log("Hello world!"); +import { returnTest2 } from './test2.js'; + +export function initButton(attempt, [buttonId, inputId, secretValue]) { + console.log("called initButton"); + + if (returnTest2() !== "test2") { + console.error("method did not return 'test2'"); + } + + document.getElementById(buttonId).addEventListener("click", function (event) { + event.target.disabled = true; + document.getElementById(inputId).value = secretValue; + }) +} + +export function hello(attempt, param) { + console.log("hello " + param); +} diff --git a/examples/static-files/js/test2.js b/examples/static-files/js/test2.js new file mode 100644 index 00000000..364c0282 --- /dev/null +++ b/examples/static-files/js/test2.js @@ -0,0 +1,3 @@ +export function returnTest2() { + return 'test2'; +} 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 6c40e24c..b5479ef8 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, FeedbackType, Question, ResponseNotScorableError from .form import MyModel class ExampleAttempt(Attempt): + def _init_attempt(self) -> None: + self.call_js("@local/static_files_example/test.js", "initButton", ["mybutton", "hiddenInput", "secret"]) + self.call_js("@local/static_files_example/test.js", "hello", "world", if_feedback_type=FeedbackType.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/templates/formulation.xhtml.j2 b/examples/static-files/templates/formulation.xhtml.j2 index e22d5125..bd5bd5ed 100644 --- a/examples/static-files/templates/formulation.xhtml.j2 +++ b/examples/static-files/templates/formulation.xhtml.j2 @@ -1,4 +1,6 @@
I have custom styling!
+ +
diff --git a/poetry.lock b/poetry.lock index e34727b9..e4c24d0e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -1415,8 +1415,8 @@ files = [] develop = false [package.dependencies] -aiohttp = "^3.10.5" -jinja2 = "^3.1.3" +aiohttp = "^3.11.11" +jinja2 = "^3.1.5" polyfactory = "^2.15.0" psutil = "^6.0.0" pydantic = "^2.8.2" @@ -1427,8 +1427,8 @@ watchdog = "^4.0.0" [package.source] type = "git" url = "https://github.com/questionpy-org/questionpy-server.git" -reference = "aca709b1dd8cef935ac4bf4bb47471959aac6779" -resolved_reference = "aca709b1dd8cef935ac4bf4bb47471959aac6779" +reference = "8ca0cc1174198af45c8083991e6005a073789a87" +resolved_reference = "8ca0cc1174198af45c8083991e6005a073789a87" [[package]] name = "ruff" @@ -1772,4 +1772,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "110ccfe1f6027f6ca9de8de333ab88a2f4551f23de27ee137d745d5eee6c4bcc" +content-hash = "6245be3a5a64bdb08beec75f46f512617f5a869b7f5eb2c03aa3e5a65f8db93f" diff --git a/pyproject.toml b/pyproject.toml index c6f8e513..735d3c7f 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 = "aca709b1dd8cef935ac4bf4bb47471959aac6779" } +questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "8ca0cc1174198af45c8083991e6005a073789a87" } jinja2 = "^3.1.3" aiohttp-jinja2 = "^1.6" lxml = "~5.1.0" diff --git a/questionpy/__init__.py b/questionpy/__init__.py index c7c9b878..1664b702 100644 --- a/questionpy/__init__.py +++ b/questionpy/__init__.py @@ -11,6 +11,8 @@ AttemptUi, CacheControl, ClassifiedResponse, + DisplayRole, + FeedbackType, ScoreModel, ScoringCode, ) @@ -61,7 +63,9 @@ "BaseScoringState", "CacheControl", "ClassifiedResponse", + "DisplayRole", "Environment", + "FeedbackType", "InvalidResponseError", "Manifest", "NeedsManualScoringError", diff --git a/questionpy/_attempt.py b/questionpy/_attempt.py index 1ec3e20e..4f70354a 100644 --- a/questionpy/_attempt.py +++ b/questionpy/_attempt.py @@ -1,3 +1,4 @@ +import json from abc import ABC, abstractmethod from collections.abc import Mapping, Sequence from functools import cached_property @@ -6,7 +7,16 @@ 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, + DisplayRole, + FeedbackType, + JsModuleCall, + ScoredInputModel, + ScoringCode, +) from ._ui import create_jinja2_environment from ._util import get_mro_type_hint @@ -75,6 +85,10 @@ def placeholders(self) -> dict[str, str]: def css_files(self) -> list[str]: pass + @property + def javascript_calls(self) -> list[JsModuleCall]: + pass + @property def files(self) -> dict[str, AttemptFile]: pass @@ -156,6 +170,9 @@ def __init__( self.cache_control = CacheControl.PRIVATE_CACHE self.placeholders: dict[str, str] = {} self.css_files: list[str] = [] + self._javascript_calls: list[JsModuleCall] = [] + """LMS has to call these JS modules/functions.""" + 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,38 @@ 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: DisplayRole | None = None, + if_feedback_type: FeedbackType | None = None, + ) -> None: + """Call a javascript function when the LMS displays this question attempt. + + The function is called when both the `if_role` and `if_feedback_type` conditions are met. + + 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 + function: Name of a callable value within the JS module + data: arbitrary data to pass to the function + if_role: Function is only called if the user has this role. + if_feedback_type: Function is only called if the user is allowed to view this feedback type. + """ + data_json = None if data is None else json.dumps(data) + call = JsModuleCall( + module=module, function=function, data=data_json, if_role=if_role, if_feedback_type=if_feedback_type + ) + self._javascript_calls.append(call) + + @property + def javascript_calls(self) -> list[JsModuleCall]: + return self._javascript_calls + 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/commands/_helper.py b/questionpy_sdk/commands/_helper.py index b4609976..e469a09e 100644 --- a/questionpy_sdk/commands/_helper.py +++ b/questionpy_sdk/commands/_helper.py @@ -12,6 +12,7 @@ from questionpy_sdk.package.builder import DirPackageBuilder from questionpy_sdk.package.errors import PackageBuildError, PackageSourceValidationError from questionpy_sdk.package.source import PackageSource +from questionpy_server.hash import calculate_hash from questionpy_server.worker.runtime.package_location import ( DirPackageLocation, PackageLocation, @@ -53,7 +54,7 @@ def get_package_location(pkg_string: str, pkg_path: Path) -> PackageLocation: return _get_dir_package_location_from_source(pkg_string, pkg_path) if zipfile.is_zipfile(pkg_path): - return ZipPackageLocation(pkg_path) + return ZipPackageLocation(pkg_path, calculate_hash(pkg_path)) msg = f"'{pkg_string}' doesn't look like a QPy package file, source directory, or dist directory." raise click.ClickException(msg) diff --git a/questionpy_sdk/webserver/question_ui/__init__.py b/questionpy_sdk/webserver/question_ui/__init__.py index 90515bf6..e6e7e2ed 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 DisplayRole from questionpy_sdk.webserver.question_ui.errors import ( ConversionError, ExpectedAncestorError, @@ -185,22 +185,15 @@ 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 right_answer: bool = True - roles: set[QuestionDisplayRole] = { - QuestionDisplayRole.DEVELOPER, - QuestionDisplayRole.PROCTOR, - QuestionDisplayRole.SCORER, - QuestionDisplayRole.TEACHER, + roles: set[DisplayRole] = { + DisplayRole.DEVELOPER, + DisplayRole.PROCTOR, + DisplayRole.SCORER, + DisplayRole.TEACHER, } readonly: bool = False @@ -330,7 +323,7 @@ def _hide_if_role(self) -> None: for element in _assert_element_list(self._xpath("//*[@qpy:if-role]")): if attr := element.get(f"{{{_QPY_NAMESPACE}}}if-role"): allowed_roles = [role.upper() for role in re.split(r"[\s|]+", attr)] - has_role = any(role in allowed_roles and role in self._options.roles for role in QuestionDisplayRole) + has_role = any(role in allowed_roles and role in self._options.roles for role in DisplayRole) if not has_role and (parent := element.getparent()) is not None: parent.remove(element) @@ -648,7 +641,7 @@ def _validate_if_role(self) -> None: for element in _assert_element_list(self._xpath("//*[@qpy:if-role]")): if attr := element.get(f"{{{_QPY_NAMESPACE}}}if-role"): allowed_roles = [role.upper() for role in re.split(r"[\s|]+", attr)] - expected = list(QuestionDisplayRole) + expected = list(DisplayRole) if unexpected := [role for role in allowed_roles if role not in expected]: error = InvalidAttributeValueError( element=element, diff --git a/tests/questionpy_sdk/webserver/test_question_ui.py b/tests/questionpy_sdk/webserver/test_question_ui.py index 7b3dd643..1ece207d 100644 --- a/tests/questionpy_sdk/webserver/test_question_ui.py +++ b/tests/questionpy_sdk/webserver/test_question_ui.py @@ -7,8 +7,8 @@ import pytest from questionpy_sdk.webserver.question_ui import ( + DisplayRole, QuestionDisplayOptions, - QuestionDisplayRole, QuestionFormulationUIRenderer, QuestionMetadata, QuestionUIRenderer, @@ -146,7 +146,7 @@ def test_should_show_inline_feedback(renderer: QuestionUIRenderer) -> None: "
", ), ( - QuestionDisplayOptions(roles={QuestionDisplayRole.SCORER}), + QuestionDisplayOptions(roles={DisplayRole.SCORER}), """
You're a scorer!