Skip to content
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

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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."""
Comment on lines +172 to +174
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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 Parameter call_only_once?

Copy link
Contributor Author

@MartinGauk MartinGauk Nov 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oder wo bei mehreren Aufrufen der letzte behalten werden sollte und nicht der erste

Die Duplikate beziehen sich auf module, function, data und if_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.


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)
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.
Für diese Nicht-Standard-Fälle könnten wir auch QPy-URIs vorschreiben. Initial würde ich das aber eh noch nicht implementieren und können wir später noch genauer überlegen.

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Keine Daten" würde ich lieber auch im Model mit None darstellen als mit dem leeren String, auch wenn das vermutlich nur stilistisch ist.

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
43 changes: 41 additions & 2 deletions 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 <[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,
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 All @@ -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:
Expand Down
13 changes: 3 additions & 10 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,16 +165,9 @@ 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
feedback: bool = True
specific_feedback: bool = True
right_answer: bool = True
roles: set[QuestionDisplayRole] = {
QuestionDisplayRole.DEVELOPER,
Expand Down Expand Up @@ -323,7 +316,7 @@ def _hide_unwanted_feedback(self) -> None:
# Check conditions to remove the element
if not (
(feedback_type == "general" and self._options.general_feedback)
or (feedback_type == "specific" and self._options.feedback)
or (feedback_type == "specific" and self._options.specific_feedback)
):
_remove_element(element)

Expand Down
5 changes: 3 additions & 2 deletions 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 @@ -80,9 +80,10 @@ async def get_attempt(request: web.Request) -> web.Response:
if not score:
# TODO: Allow manually set display options to override this.
display_options.readonly = False
display_options.general_feedback = display_options.feedback = display_options.right_answer = False
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