diff --git a/poetry.lock b/poetry.lock
index 9673d52a..5cddc48a 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
[[package]]
name = "aiohappyeyeballs"
@@ -1696,7 +1696,7 @@ files = [
[[package]]
name = "questionpy-server"
-version = "0.8.0"
+version = "0.9.0"
description = "QuestionPy application server"
optional = false
python-versions = ">=3.12,<4.0"
@@ -1717,8 +1717,8 @@ watchdog = ">=6.0.0,<7.0.0"
[package.source]
type = "git"
url = "https://github.com/questionpy-org/questionpy-server.git"
-reference = "a9f052d552e68268684792e028279feabae2c2e2"
-resolved_reference = "a9f052d552e68268684792e028279feabae2c2e2"
+reference = "06d63e6f4a3bf31ec182e219b5025a1c3b45f70c"
+resolved_reference = "06d63e6f4a3bf31ec182e219b5025a1c3b45f70c"
[[package]]
name = "requests"
@@ -2028,4 +2028,4 @@ propcache = ">=0.2.1"
[metadata]
lock-version = "2.1"
python-versions = ">=3.12, <4.0"
-content-hash = "95f25c50a2b7014c9239612f65b428ae732bfc6da1692cffdbfd330fe0d78825"
+content-hash = "a138a26b3d1a1cbafd0713f6e731ab09f291fb2a8160fa84efb44b4b6df6b9f5"
diff --git a/pyproject.toml b/pyproject.toml
index 988a8d61..7c0a1d5f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ name = "questionpy-sdk"
description = "Library and toolset for the development of QuestionPy packages"
license = "MIT"
urls = { homepage = "https://questionpy.org" }
-version = "0.6.0"
+version = "0.7.0"
authors = [
{ name = "TU Berlin innoCampus" },
{ email = "info@isis.tu-berlin.de" }
@@ -18,7 +18,7 @@ dependencies = [
"aiohttp >=3.11.18, <4.0.0",
"pydantic >=2.11.4, <3.0.0",
"PyYAML >=6.0.2, <7.0.0",
- "questionpy-server @ git+https://github.com/questionpy-org/questionpy-server.git@a9f052d552e68268684792e028279feabae2c2e2",
+ "questionpy-server @ git+https://github.com/questionpy-org/questionpy-server.git@06d63e6f4a3bf31ec182e219b5025a1c3b45f70c",
"jinja2 >=3.1.6, <4.0.0",
"aiohttp-jinja2 >=1.6, <2.0",
"lxml[html-clean] >=5.4.0, <5.5.0",
diff --git a/questionpy/__init__.py b/questionpy/__init__.py
index ca26880e..bd20a609 100644
--- a/questionpy/__init__.py
+++ b/questionpy/__init__.py
@@ -16,6 +16,7 @@
ScoreModel,
ScoringCode,
)
+from questionpy_common.api.files import EditorData, ResponseFile
from questionpy_common.api.qtype import OptionsFormValidationError, QuestionTypeInterface
from questionpy_common.api.question import (
PossibleResponse,
@@ -63,6 +64,7 @@
"CacheControl",
"ClassifiedResponse",
"DisplayRole",
+ "EditorData",
"Environment",
"FeedbackType",
"InvalidResponseError",
@@ -83,6 +85,7 @@
"QuestionTypeWrapper",
"QuestionWrapper",
"RequestInfo",
+ "ResponseFile",
"ResponseNotScorableError",
"ScoreModel",
"ScoringCode",
diff --git a/questionpy/_attempt.py b/questionpy/_attempt.py
index 92d50ef6..e1a91691 100644
--- a/questionpy/_attempt.py
+++ b/questionpy/_attempt.py
@@ -18,6 +18,7 @@
ScoredInputModel,
ScoringCode,
)
+from questionpy_common.api.files import EditorData, ResponseFile
from ._ui import create_jinja2_environment
from ._util import get_package_by_attempt, reify_type_hint
@@ -129,11 +130,18 @@ def __init__(
attempt_state: BaseAttemptState,
scoring_state: BaseScoringState | None = None,
response: dict[str, JsonValue] | None = None,
+ uploads: dict[str, list[ResponseFile]] | None = None,
+ editors: dict[str, EditorData[ResponseFile]] | None = None,
) -> None:
self.question = question
self.attempt_state = attempt_state
- self.response = response
self.scoring_state = scoring_state
+ self.response = response
+ """The values entered into all "normal" input fields by the student, by their names."""
+ self.uploads = uploads
+ """The files uploaded to any `` elements you defined, by their names."""
+ self.editors = editors
+ """The text entered and files uploaded in any `` elements you defined, by their names."""
self.cache_control = CacheControl.PRIVATE_CACHE
self.placeholders: dict[str, str | TranslatableString] = {}
diff --git a/questionpy/_qtype.py b/questionpy/_qtype.py
index a9d836a0..1bbd436b 100644
--- a/questionpy/_qtype.py
+++ b/questionpy/_qtype.py
@@ -6,6 +6,7 @@
from pydantic import BaseModel, JsonValue, ValidationError
+from questionpy_common.api.files import EditorData, ResponseFile
from questionpy_common.api.qtype import InvalidQuestionStateError, OptionsFormValidationError
from questionpy_common.api.question import ScoringMethod, SubquestionModel
from questionpy_common.environment import get_qpy_environment
@@ -156,24 +157,28 @@ def get_attempt(
attempt_state: dict[str, JsonValue],
scoring_state: dict[str, JsonValue] | None = None,
response: dict[str, JsonValue] | None = None,
+ uploads: dict[str, list[ResponseFile]] | None = None,
+ editors: dict[str, EditorData[ResponseFile]] | None = None,
) -> AttemptProtocol:
parsed_attempt_state = self.attempt_class.attempt_state_class.model_validate(attempt_state)
parsed_scoring_state = None
if scoring_state is not None:
parsed_scoring_state = self.attempt_class.scoring_state_class.model_validate(scoring_state)
- return self.attempt_class(self, parsed_attempt_state, parsed_scoring_state, response)
+ return self.attempt_class(self, parsed_attempt_state, parsed_scoring_state, response, uploads, editors)
def score_attempt(
self,
attempt_state: dict[str, JsonValue],
scoring_state: dict[str, JsonValue] | None,
- response: dict[str, JsonValue] | None,
+ response: dict[str, JsonValue],
+ uploads: dict[str, list[ResponseFile]],
+ editors: dict[str, EditorData[ResponseFile]],
*,
compute_adjusted_score: bool,
generate_hint: bool,
) -> AttemptScoredProtocol:
- attempt = cast("Attempt", self.get_attempt(attempt_state, scoring_state, response))
+ attempt = cast("Attempt", self.get_attempt(attempt_state, scoring_state, response, uploads, editors))
attempt.score_response(compute_adjusted_score=compute_adjusted_score, generate_hint=generate_hint)
return cast("AttemptScoredProtocol", attempt)
diff --git a/questionpy/_wrappers/_question.py b/questionpy/_wrappers/_question.py
index 40296333..0db598e7 100644
--- a/questionpy/_wrappers/_question.py
+++ b/questionpy/_wrappers/_question.py
@@ -9,6 +9,7 @@
from questionpy._attempt import AttemptProtocol, AttemptScoredProtocol
from questionpy_common import TranslatableString
from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel, AttemptUi
+from questionpy_common.api.files import EditorData, ResponseFile
from questionpy_common.api.question import QuestionInterface, QuestionModel
from questionpy_common.environment import get_qpy_environment
from questionpy_common.manifest import Bcp47LanguageTag
@@ -78,21 +79,28 @@ def start_attempt(self, variant: int) -> AttemptStartedModel:
return AttemptStartedModel(**_export_attempt(attempt), attempt_state=json.dumps(plain_attempt_state))
def get_attempt(
- self, attempt_state: str, scoring_state: str | None = None, response: dict[str, JsonValue] | None = None
+ self,
+ attempt_state: str,
+ scoring_state: str | None = None,
+ response: dict[str, JsonValue] | None = None,
+ uploads: dict[str, list[ResponseFile]] | None = None,
+ editors: dict[str, EditorData[ResponseFile]] | None = None,
) -> AttemptModel:
parsed_attempt_state = json.loads(attempt_state)
parsed_scoring_state = None
if scoring_state:
parsed_scoring_state = json.loads(scoring_state)
- attempt = self._question.get_attempt(parsed_attempt_state, parsed_scoring_state, response)
+ attempt = self._question.get_attempt(parsed_attempt_state, parsed_scoring_state, response, uploads, editors)
return AttemptModel(**_export_attempt(attempt))
def score_attempt(
self,
attempt_state: str,
- scoring_state: str | None = None,
- response: dict[str, JsonValue] | None = None,
+ scoring_state: str | None,
+ response: dict[str, JsonValue],
+ uploads: dict[str, list[ResponseFile]],
+ editors: dict[str, EditorData[ResponseFile]],
*,
compute_adjusted_score: bool = False,
generate_hint: bool = False,
@@ -106,6 +114,8 @@ def score_attempt(
parsed_attempt_state,
parsed_scoring_state,
response,
+ uploads,
+ editors,
compute_adjusted_score=compute_adjusted_score,
generate_hint=generate_hint,
)
diff --git a/questionpy/form/__init__.py b/questionpy/form/__init__.py
index f039d5fe..d568bb01 100644
--- a/questionpy/form/__init__.py
+++ b/questionpy/form/__init__.py
@@ -41,6 +41,7 @@
# (c) Technische Universität Berlin, innoCampus
# We reexport these for them to be used in file upload elements.
+from questionpy_common.api.files import OptionsFile
from questionpy_common.constants import GiB, KiB, MiB
from questionpy_common.elements import (
CanHaveConditions,
@@ -81,7 +82,7 @@
text_area,
text_input,
)
-from ._model import FormModel, OptionEnum, OptionsFile, RichTextEditor
+from ._model import FormModel, OptionEnum
__all__ = [
"CanHaveConditions",
@@ -101,7 +102,6 @@
"OptionsFormDefinition",
"RadioGroupElement",
"RepetitionElement",
- "RichTextEditor",
"SelectElement",
"StaticTextElement",
"TextAreaElement",
diff --git a/questionpy/form/_dsl.py b/questionpy/form/_dsl.py
index 8593bbcc..b55c62c7 100644
--- a/questionpy/form/_dsl.py
+++ b/questionpy/form/_dsl.py
@@ -9,6 +9,7 @@
from pydantic_core import PydanticUndefined
from questionpy_common import TranslatableString
+from questionpy_common.api.files import EditorData, OptionsFile
from questionpy_common.conditions import Condition, DoesNotEqual, Equals, In, IsChecked, IsNotChecked
from questionpy_common.elements import (
CheckboxElement,
@@ -30,8 +31,6 @@
from ._model import (
FormModel,
OptionEnum,
- OptionsFile,
- RichTextEditor,
_FieldInfo,
_OptionInfo,
_SectionInfo,
@@ -689,7 +688,7 @@ def rich_text_editor(
upload_max_bytes_per_file: int | None = None,
upload_max_bytes_total: int | None = None,
help: str | TranslatableString | None = None,
-) -> RichTextEditor:
+) -> EditorData[OptionsFile]:
"""Adds a rich text editor element, usually a WYSIWYG-style editor, depending on what the LMS provides.
If the LMS supports uploading and embedding files in its editor, they will be available in the `files` attribute,
@@ -722,9 +721,9 @@ def rich_text_editor(
)
return cast(
- "RichTextEditor",
+ "EditorData[OptionsFile]",
_FieldInfo(
- type=RichTextEditor,
+ type=EditorData[OptionsFile],
build=lambda name: WysiwygEditorElement(name=name, label=label, help=help, file_uploads=upload_options),
),
)
diff --git a/questionpy/form/_model.py b/questionpy/form/_model.py
index 0458ae04..cd5606cf 100644
--- a/questionpy/form/_model.py
+++ b/questionpy/form/_model.py
@@ -3,12 +3,11 @@
# (c) Technische Universität Berlin, innoCampus
from collections.abc import Callable, Sequence
from dataclasses import dataclass
-from datetime import datetime
from enum import Enum
from itertools import starmap
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, get_args, get_origin
-from pydantic import BaseModel, ByteSize, ConfigDict, Field
+from pydantic import BaseModel, Field
from pydantic._internal._model_construction import ModelMetaclass # noqa: PLC2701
from pydantic.fields import FieldInfo
from pydantic_core import CoreSchema, core_schema
@@ -17,47 +16,6 @@
from questionpy_common.elements import FormElement, FormSection, OptionsFormDefinition
-class OptionsFile(BaseModel):
- """Metadata about a file uploaded in question options.
-
- The file content is stored by the LMS and can be retrieved using the
- [`file_ref`][questionpy.form.OptionsFile.file_ref] or embedded in the question using
- [`uri`][questionpy.form.OptionsFile.uri]. The filename can be freely changed without informing the LMS.
-
- See Also:
- - [questionpy.form.rich_text_editor][]
- - [questionpy.form.file_upload][]
- """
-
- path: str
- """The folder path of this files. Must begin and end in `/`. Top-level files have a path of `/`."""
- filename: str
- file_ref: str
- """An opaque reference that can be used to retrieve the file content from the LMS."""
- uploaded_at: datetime
- mime_type: str
- size: ByteSize
-
- @property
- def uri(self) -> str:
- """Builds a URI that can be used to embed the file in a question."""
- return f"qpy://options/{self.file_ref}"
-
-
-class RichTextEditor(BaseModel):
- """The form data generated by a [questionpy.form.rich_text_editor][] element."""
-
- # The LMS may send and expect to receive back again additional properties. They must begin with _, though we don't
- # check that restriction yet.
- model_config = ConfigDict(extra="allow")
-
- text: str
- """The text content of the editor, in whichever markup format the LMS uses."""
-
- files: list[OptionsFile] = []
- """Files referenced by the markup."""
-
-
@dataclass
class _OptionInfo:
label: str | TranslatableString
diff --git a/questionpy_sdk/webserver/controllers/attempt/controller.py b/questionpy_sdk/webserver/controllers/attempt/controller.py
index 5f4c2322..85633682 100644
--- a/questionpy_sdk/webserver/controllers/attempt/controller.py
+++ b/questionpy_sdk/webserver/controllers/attempt/controller.py
@@ -94,6 +94,8 @@ async def score_attempt(self, question_id: str, attempt_id: str) -> None:
question_state=await self._state_manager.read_question_state(question_id),
attempt_state=await self._state_manager.read_attempt_state(question_id, attempt_id),
response=await self._state_manager.read_attempt_data(question_id, attempt_id),
+ uploads={},
+ editors={}, # TODO: Implement uploads and editors in SDK frontend. (#245)
scoring_state=score.scoring_state if score else None,
)
diff --git a/tests/questionpy/wrappers/test_question.py b/tests/questionpy/wrappers/test_question.py
index c9d4a42c..89dfeba6 100644
--- a/tests/questionpy/wrappers/test_question.py
+++ b/tests/questionpy/wrappers/test_question.py
@@ -46,7 +46,13 @@ def test_should_get_attempt(package: Package) -> None:
def test_score_attempt_should_return_automatically_scored(package: Package) -> None:
qtype = QuestionTypeWrapper(QuestionUsingMyQuestionState, package)
question = qtype.create_question_from_state(json.dumps(QUESTION_STATE_DICT))
- attempt_scored_model = question.score_attempt(json.dumps(ATTEMPT_STATE_DICT))
+ attempt_scored_model = question.score_attempt(
+ attempt_state=json.dumps(ATTEMPT_STATE_DICT),
+ scoring_state=None,
+ response={},
+ uploads={},
+ editors={},
+ )
assert attempt_scored_model == AttemptScoredModel(
lang="en",
@@ -75,7 +81,13 @@ def test_score_attempt_should_handle_scoring_error(
assert isinstance(question, QuestionWrapper)
with patch.object(SomeAttempt, "_compute_score") as method:
method.side_effect = error
- attempt_scored_model = question.score_attempt(json.dumps(ATTEMPT_STATE_DICT))
+ attempt_scored_model = question.score_attempt(
+ attempt_state=json.dumps(ATTEMPT_STATE_DICT),
+ scoring_state=None,
+ response={},
+ uploads={},
+ editors={},
+ )
assert attempt_scored_model == AttemptScoredModel(
lang="en",