Skip to content
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
10 changes: 5 additions & 5 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions questionpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -63,6 +64,7 @@
"CacheControl",
"ClassifiedResponse",
"DisplayRole",
"EditorData",
"Environment",
"FeedbackType",
"InvalidResponseError",
Expand All @@ -83,6 +85,7 @@
"QuestionTypeWrapper",
"QuestionWrapper",
"RequestInfo",
"ResponseFile",
"ResponseNotScorableError",
"ScoreModel",
"ScoringCode",
Expand Down
10 changes: 9 additions & 1 deletion questionpy/_attempt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<qpy:file-upload/>` elements you defined, by their names."""
self.editors = editors
"""The text entered and files uploaded in any `<qpy:rich-text-editor/>` elements you defined, by their names."""

self.cache_control = CacheControl.PRIVATE_CACHE
self.placeholders: dict[str, str | TranslatableString] = {}
Expand Down
11 changes: 8 additions & 3 deletions questionpy/_qtype.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
18 changes: 14 additions & 4 deletions questionpy/_wrappers/_question.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down
4 changes: 2 additions & 2 deletions questionpy/form/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
# (c) Technische Universität Berlin, innoCampus <info@isis.tu-berlin.de>

# 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,
Expand Down Expand Up @@ -81,7 +82,7 @@
text_area,
text_input,
)
from ._model import FormModel, OptionEnum, OptionsFile, RichTextEditor
from ._model import FormModel, OptionEnum

__all__ = [
"CanHaveConditions",
Expand All @@ -101,7 +102,6 @@
"OptionsFormDefinition",
"RadioGroupElement",
"RepetitionElement",
"RichTextEditor",
"SelectElement",
"StaticTextElement",
"TextAreaElement",
Expand Down
9 changes: 4 additions & 5 deletions questionpy/form/_dsl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,8 +31,6 @@
from ._model import (
FormModel,
OptionEnum,
OptionsFile,
RichTextEditor,
_FieldInfo,
_OptionInfo,
_SectionInfo,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
),
)
Expand Down
44 changes: 1 addition & 43 deletions questionpy/form/_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
# (c) Technische Universität Berlin, innoCampus <info@isis.tu-berlin.de>
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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions questionpy_sdk/webserver/controllers/attempt/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
16 changes: 14 additions & 2 deletions tests/questionpy/wrappers/test_question.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down