Skip to content

Commit

Permalink
feat: allow javascript in attempts and small demo
Browse files Browse the repository at this point in the history
  • Loading branch information
MartinGauk committed Oct 29, 2024
1 parent 340be86 commit 0920cbd
Show file tree
Hide file tree
Showing 13 changed files with 251 additions and 18 deletions.
14 changes: 13 additions & 1 deletion examples/static-files/js/test.js
Original file line number Diff line number Diff line change
@@ -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);
},
}
});
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<div xmlns="http://www.w3.org/1999/xhtml"
xmlns:qpy="http://questionpy.org/ns/question">
<div class="my-custom-class">I have custom styling!</div>
<input type="hidden" name="hidden_value" id="hiddenInput" />
<input type="button" id="mybutton" value="click here for a 1.0 score" />
</div>
6 changes: 3 additions & 3 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions questionpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
AttemptUi,
CacheControl,
ClassifiedResponse,
JsModuleCallRoleFeedback,
ScoreModel,
ScoringCode,
)
Expand Down Expand Up @@ -63,6 +64,7 @@
"ClassifiedResponse",
"Environment",
"InvalidResponseError",
"JsModuleCallRoleFeedback",
"Manifest",
"NeedsManualScoringError",
"NoEnvironmentError",
Expand Down
53 changes: 51 additions & 2 deletions questionpy/_attempt.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions questionpy/_wrappers/_question.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down
41 changes: 40 additions & 1 deletion questionpy_sdk/webserver/attempt.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
# (c) Technische Universität Berlin, innoCampus <info@isis.tu-berlin.de>
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,
Expand All @@ -15,6 +22,7 @@
class _AttemptRenderContext(TypedDict):
attempt_status: Literal["Started", "In progress", "Scored"]

manifest: Manifest
attempt: AttemptModel
attempt_state: str

Expand All @@ -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,
*,
Expand All @@ -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,
Expand Down
9 changes: 1 addition & 8 deletions questionpy_sdk/webserver/question_ui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from __future__ import annotations

import re
from enum import StrEnum
from random import Random
from typing import Any

Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion questionpy_sdk/webserver/routes/attempt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 0920cbd

Please sign in to comment.