Skip to content

Commit

Permalink
feat: allow javascript in attempts and simple demo
Browse files Browse the repository at this point in the history
  • Loading branch information
MartinGauk committed Jan 16, 2025
1 parent f80ae0a commit b77b3e9
Show file tree
Hide file tree
Showing 12 changed files with 114 additions and 28 deletions.
19 changes: 18 additions & 1 deletion examples/static-files/js/test.js
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 3 additions & 0 deletions examples/static-files/js/test2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function returnTest2() {
return 'test2';
}
Original file line number Diff line number Diff line change
@@ -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)

Check failure on line 9 in examples/static-files/python/local/static_files_example/question_type.py

View workflow job for this annotation

GitHub Actions / ci / ruff-lint

Ruff (E501)

examples/static-files/python/local/static_files_example/question_type.py:9:121: E501 Line too long (125 > 120)

Check failure on line 9 in examples/static-files/python/local/static_files_example/question_type.py

View workflow job for this annotation

GitHub Actions / ci / ruff-lint

Ruff (E501)

examples/static-files/python/local/static_files_example/question_type.py:9:121: E501 Line too long (125 > 120)

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
2 changes: 2 additions & 0 deletions examples/static-files/templates/formulation.xhtml.j2
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>
12 changes: 6 additions & 6 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 = "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"
Expand Down
4 changes: 4 additions & 0 deletions questionpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
AttemptUi,
CacheControl,
ClassifiedResponse,
DisplayRole,
FeedbackType,
ScoreModel,
ScoringCode,
)
Expand Down Expand Up @@ -61,7 +63,9 @@
"BaseScoringState",
"CacheControl",
"ClassifiedResponse",
"DisplayRole",
"Environment",
"FeedbackType",
"InvalidResponseError",
"Manifest",
"NeedsManualScoringError",
Expand Down
56 changes: 55 additions & 1 deletion questionpy/_attempt.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from functools import cached_property
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
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,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)

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
3 changes: 2 additions & 1 deletion questionpy_sdk/commands/_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 8 additions & 15 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 DisplayRole
from questionpy_sdk.webserver.question_ui.errors import (
ConversionError,
ExpectedAncestorError,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions tests/questionpy_sdk/webserver/test_question_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
import pytest

from questionpy_sdk.webserver.question_ui import (
DisplayRole,
QuestionDisplayOptions,
QuestionDisplayRole,
QuestionFormulationUIRenderer,
QuestionMetadata,
QuestionUIRenderer,
Expand Down Expand Up @@ -146,7 +146,7 @@ def test_should_show_inline_feedback(renderer: QuestionUIRenderer) -> None:
"<div></div>",
),
(
QuestionDisplayOptions(roles={QuestionDisplayRole.SCORER}),
QuestionDisplayOptions(roles={DisplayRole.SCORER}),
"""
<div>
<div>You're a scorer!</div>
Expand Down

0 comments on commit b77b3e9

Please sign in to comment.