-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
JavaScript in Attempts ermöglichen #133
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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,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> |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
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 | ||
|
@@ -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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Können wir irgendwie mögliche Kollisionen mit einem [subdir] namens "attempt" vermeiden? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Eventuell ist auch die Frage, ob wir diesen Fall (attempt files) wirklich unterstützen wollen/müssen. Es gibt ja außerdem noch andere Bereiche, in denen das Paket Dateien ablegen kann. |
||
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "Keine Daten" würde ich lieber auch im Model mit |
||
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) | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,7 +3,14 @@ | |
# (c) Technische Universität Berlin, innoCampus <[email protected]> | ||
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, | ||
|
@@ -68,7 +107,7 @@ def get_attempt_render_context( | |
context["general_feedback"] = html | ||
if errors: | ||
context["render_errors"]["General Feedback"] = errors | ||
if display_options.feedback and attempt.ui.specific_feedback: | ||
if display_options.specific_feedback and attempt.ui.specific_feedback: | ||
html, errors = QuestionUIRenderer(attempt.ui.specific_feedback, *renderer_args).render() | ||
context["specific_feedback"] = html | ||
if errors: | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Warum möchten wir keine Duplikate erlauben?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Habe ich in Tefen unter 5. beschrieben.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, damit schränken wir aber allgemein die Funktionalität ein, um ein spezifisches Problem zu lösen. Es gibt sicher auch Funktionen, die mehrmals aufgerufen werden müssen, oder wo bei mehreren Aufrufen der letzte behalten werden sollte und nicht der erste. Jedenfalls ist es für die Paketentwickler*innen so nicht intuitiv.
Vielleicht kannst du eine zweite Funktion
call_js_once
hinzufügen, die keine Duplikate erlaubt, oder einen Parametercall_only_once
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Die Duplikate beziehen sich auf
module
,function
,data
undif_role/feedback_type
. Oder warst du davon ausgegangen, dass nur nach Modul/Funktion gegangen wird?Der Use-Case wird mir nicht klar, warum man eine Funktion mit denselben Daten mehrmals aufrufen sollte. Also ich glaube das ist eher ein unerwünschtes Verhalten, zu dem es aber schnell kommen kann, wenn man mehrere gleichartige Subquestions hat.
Wenn das Verhalten bleibt, sollte dies aber in jedem Fall noch in der
call_js
Methode dokumentiert werden.