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",