Skip to content

Commit e67632a

Browse files
committed
feat: pass editor data and uploads to package
1 parent a9f052d commit e67632a

File tree

9 files changed

+130
-14
lines changed

9 files changed

+130
-14
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ name = "questionpy-server"
77
description = "QuestionPy application server"
88
license = { file = "LICENSE.md" }
99
urls = { homepage = "https://questionpy.org" }
10-
version = "0.8.0"
10+
version = "0.9.0"
1111
authors = [
1212
{ name = "TU Berlin innoCampus" },
1313
{ email = "[email protected]" }

questionpy_common/api/files.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from abc import ABC, abstractmethod
2+
from datetime import datetime
3+
from typing import Annotated
4+
5+
from pydantic import BaseModel, ByteSize, ConfigDict, StringConstraints
6+
7+
8+
class UserUploadedFile(BaseModel, ABC):
9+
"""Metadata about a file uploaded by a user to the LMS."""
10+
11+
path: Annotated[str, StringConstraints(pattern=r"^/(.+/)?$")]
12+
"""The folder path of this file. Must begin and end in `/`. Top-level files have a path of `/`."""
13+
14+
filename: str
15+
16+
file_ref: str
17+
"""An opaque reference that can be used to retrieve the file content from the LMS."""
18+
19+
uploaded_at: datetime
20+
21+
mime_type: str
22+
23+
size: ByteSize
24+
25+
@property
26+
@abstractmethod
27+
def uri(self) -> str:
28+
"""Builds a URI that can be used to embed the file in a question."""
29+
30+
31+
class ResponseFile(UserUploadedFile):
32+
@property
33+
def uri(self) -> str:
34+
return f"qpy://response/{self.file_ref}"
35+
36+
37+
class OptionsFile(UserUploadedFile):
38+
@property
39+
def uri(self) -> str:
40+
return f"qpy://options/{self.file_ref}"
41+
42+
43+
class EditorData[FileT: UserUploadedFile](BaseModel):
44+
"""Data submitted by rich text editors."""
45+
46+
# The LMS may send and expect to receive back again additional properties. They must begin with _, though we don't
47+
# check that restriction yet.
48+
model_config = ConfigDict(extra="allow")
49+
50+
text: str
51+
"""The text content of the editor, in whichever markup format the LMS uses."""
52+
53+
files: list[FileT] = []
54+
"""Files referenced by the markup."""

questionpy_common/api/question.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
"SubquestionModel",
2020
]
2121

22+
from .files import EditorData, ResponseFile
23+
2224

2325
class ScoringMethod(Enum):
2426
ALWAYS_MANUAL_SCORING_REQUIRED = "ALWAYS_MANUAL_SCORING_REQUIRED"
@@ -68,23 +70,33 @@ def start_attempt(self, variant: int) -> AttemptStartedModel:
6870

6971
@abstractmethod
7072
def get_attempt(
71-
self, attempt_state: str, scoring_state: str | None = None, response: dict[str, JsonValue] | None = None
73+
self,
74+
attempt_state: str,
75+
scoring_state: str | None = None,
76+
response: dict[str, JsonValue] | None = None,
77+
uploads: dict[str, list[ResponseFile]] | None = None,
78+
editors: dict[str, EditorData] | None = None,
7279
) -> AttemptModel:
7380
"""Create an attempt object for a previously started attempt.
7481
7582
Args:
7683
attempt_state: The `attempt_state` attribute of an attempt which was previously returned by
7784
[start_attempt][].
7885
scoring_state: Not implemented.
79-
response: The response currently entered by the student.
86+
response: The response currently entered by the student, excluding uploads and editors.
87+
uploads: Files uploaded by the student in file upload elements (files belonging to editors are passed in
88+
`editors`.)
89+
editors: WYSIWYG editor responses entered by the student.
8090
"""
8191

8292
@abstractmethod
8393
def score_attempt(
8494
self,
8595
attempt_state: str,
86-
scoring_state: str | None = None,
87-
response: dict[str, JsonValue] | None = None,
96+
scoring_state: str | None,
97+
response: dict[str, JsonValue],
98+
uploads: dict[str, list[ResponseFile]],
99+
editors: dict[str, EditorData],
88100
*,
89101
compute_adjusted_score: bool = False,
90102
generate_hint: bool = False,
@@ -95,7 +107,10 @@ def score_attempt(
95107
attempt_state: The `attempt_state` attribute of an attempt which was previously returned by
96108
[start_attempt][].
97109
scoring_state: Not implemented.
98-
response: The response currently entered by the student.
110+
response: The response currently entered by the student, excluding uploads and editors.
111+
uploads: Files uploaded by the student in file upload elements (files belonging to editors are passed in
112+
`editors`.)
113+
editors: WYSIWYG editor responses entered by the student.
99114
compute_adjusted_score: TBD
100115
generate_hint: TBD
101116
"""

questionpy_server/models.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
# (c) Technische Universität Berlin, innoCampus <[email protected]>
44

55
from enum import Enum
6-
from typing import Annotated, Any
6+
from typing import Annotated
77

8-
from pydantic import BaseModel, ByteSize, ConfigDict, Field
8+
from pydantic import BaseModel, ByteSize, ConfigDict, Field, JsonValue
99

1010
from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel
11+
from questionpy_common.api.files import EditorData, ResponseFile
1112
from questionpy_common.api.question import LmsPermissions, QuestionModel
1213
from questionpy_common.elements import OptionsFormDefinition
1314
from questionpy_common.environment import LmsProvidedAttributes as EnvironmentLmsProvidedAttributes
@@ -95,11 +96,15 @@ class AttemptStartArguments(RequestBaseData, LmsProvidedAttributes):
9596
class AttemptViewArguments(RequestBaseData, LmsProvidedAttributes):
9697
attempt_state: str
9798
scoring_state: str | None = None
98-
response: dict[str, Any] | None = None
99+
response: dict[str, JsonValue] | None = None
100+
uploads: dict[str, list[ResponseFile]] | None = None
101+
editors: dict[str, EditorData[ResponseFile]] | None = None
99102

100103

101104
class AttemptScoreArguments(AttemptViewArguments):
102-
response: dict[str, Any]
105+
response: dict[str, JsonValue]
106+
uploads: dict[str, list[ResponseFile]] = {}
107+
editors: dict[str, EditorData[ResponseFile]] = {}
103108
generate_hint: bool
104109

105110

questionpy_server/web/_routes/_attempts.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ async def post_attempt_view(
4444
attempt_state=data.attempt_state,
4545
scoring_state=data.scoring_state,
4646
response=data.response,
47+
uploads=data.uploads,
48+
editors=data.editors,
4749
)
4850
packages = context.worker.get_loaded_packages()
4951

@@ -63,6 +65,8 @@ async def post_attempt_score(
6365
attempt_state=data.attempt_state,
6466
scoring_state=data.scoring_state,
6567
response=data.response,
68+
uploads=data.uploads,
69+
editors=data.editors,
6670
)
6771
packages = context.worker.get_loaded_packages()
6872

questionpy_server/worker/__init__.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pydantic import BaseModel
1111

1212
from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel
13+
from questionpy_common.api.files import EditorData, ResponseFile
1314
from questionpy_common.api.question import LmsPermissions
1415
from questionpy_common.elements import OptionsFormDefinition
1516
from questionpy_common.environment import PackagePermissions, RequestInfo
@@ -170,6 +171,8 @@ async def get_attempt(
170171
attempt_state: str,
171172
scoring_state: str | None = None,
172173
response: dict | None = None,
174+
uploads: dict[str, list[ResponseFile]] | None = None,
175+
editors: dict[str, EditorData[ResponseFile]] | None = None,
173176
) -> AttemptModel:
174177
"""Create an attempt object for a previously started attempt.
175178
@@ -178,8 +181,11 @@ async def get_attempt(
178181
question_state: The question the attempt belongs to.
179182
attempt_state: The `attempt_state` attribute of an attempt which was previously returned by
180183
:meth:`start_attempt`.
181-
scoring_state: Not implemented.
184+
scoring_state: The scoring state returned by the package the last time this attempt was scored.
182185
response: The response currently entered by the student.
186+
uploads: Files uploaded by the student in file upload elements (files belonging to editors are passed in
187+
`editors`.)
188+
editors: The WYSIWYG editor responses are passed separately here. Validated to a model in the SDK.
183189
184190
Returns:
185191
Metadata of the attempt.
@@ -194,8 +200,22 @@ async def score_attempt(
194200
attempt_state: str,
195201
scoring_state: str | None = None,
196202
response: dict,
203+
uploads: dict[str, list[ResponseFile]],
204+
editors: dict[str, EditorData[ResponseFile]],
197205
) -> AttemptScoredModel:
198-
"""TODO: write docstring."""
206+
"""Score the given response in this attempt.
207+
208+
Args:
209+
request_info: Information about the current request.
210+
question_state: The question the attempt belongs to.
211+
attempt_state: The `attempt_state` attribute of an attempt which was previously returned by
212+
:meth:`start_attempt`.
213+
scoring_state: The scoring state returned by the package the last time this attempt was scored.
214+
response: The response currently entered by the student.
215+
uploads: Files uploaded by the student in file upload elements (files belonging to editors are passed in
216+
`editors`.)
217+
editors: The WYSIWYG editor responses are passed separately here. Validated to a model in the SDK.
218+
"""
199219

200220
@abstractmethod
201221
async def get_static_file(self, path: str) -> PackageFileData:

questionpy_server/worker/impl/_base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from zipfile import ZipFile
1616

1717
from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel
18+
from questionpy_common.api.files import EditorData, ResponseFile
1819
from questionpy_common.api.question import LmsPermissions
1920
from questionpy_common.constants import DIST_DIR
2021
from questionpy_common.elements import OptionsFormDefinition
@@ -264,12 +265,16 @@ async def get_attempt(
264265
attempt_state: str,
265266
scoring_state: str | None = None,
266267
response: dict | None = None,
268+
uploads: dict[str, list[ResponseFile]] | None = None,
269+
editors: dict[str, EditorData[ResponseFile]] | None = None,
267270
) -> AttemptModel:
268271
msg = ViewAttempt(
269272
question_state=question_state,
270273
attempt_state=attempt_state,
271274
scoring_state=scoring_state,
272275
response=response,
276+
uploads=uploads,
277+
editors=editors,
273278
request_info=request_info,
274279
)
275280
ret = await self.send_and_wait_for_response(msg, ViewAttempt.Response)
@@ -284,12 +289,16 @@ async def score_attempt(
284289
attempt_state: str,
285290
scoring_state: str | None = None,
286291
response: dict,
292+
uploads: dict[str, list[ResponseFile]],
293+
editors: dict[str, EditorData[ResponseFile]],
287294
) -> AttemptScoredModel:
288295
msg = ScoreAttempt(
289296
question_state=question_state,
290297
attempt_state=attempt_state,
291298
scoring_state=scoring_state,
292299
response=response,
300+
uploads=uploads,
301+
editors=editors,
293302
request_info=request_info,
294303
)
295304
ret = await self.send_and_wait_for_response(msg, ScoreAttempt.Response)

questionpy_server/worker/runtime/manager.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,9 @@ def on_msg_view_attempt(self, msg: ViewAttempt) -> ViewAttempt.Response:
274274

275275
with self._with_request_info(msg, msg.request_info):
276276
question = self._question_type.create_question_from_state(msg.question_state)
277-
attempt_model = question.get_attempt(msg.attempt_state, msg.scoring_state, msg.response)
277+
attempt_model = question.get_attempt(
278+
msg.attempt_state, msg.scoring_state, msg.response, msg.uploads, msg.editors
279+
)
278280
return ViewAttempt.Response(attempt_model=attempt_model)
279281

280282
def on_msg_score_attempt(self, msg: ScoreAttempt) -> ScoreAttempt.Response:
@@ -285,7 +287,9 @@ def on_msg_score_attempt(self, msg: ScoreAttempt) -> ScoreAttempt.Response:
285287

286288
with self._with_request_info(msg, msg.request_info):
287289
question = self._question_type.create_question_from_state(msg.question_state)
288-
attempt_scored_model = question.score_attempt(msg.attempt_state, msg.scoring_state, msg.response)
290+
attempt_scored_model = question.score_attempt(
291+
msg.attempt_state, msg.scoring_state, msg.response, msg.uploads, msg.editors
292+
)
289293
return ScoreAttempt.Response(attempt_scored_model=attempt_scored_model)
290294

291295
@staticmethod

questionpy_server/worker/runtime/messages.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from pydantic import BaseModel, JsonValue
1212

1313
from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel
14+
from questionpy_common.api.files import EditorData, ResponseFile
1415
from questionpy_common.api.qtype import InvalidQuestionStateError, OptionsFormValidationError
1516
from questionpy_common.api.question import QuestionModel
1617
from questionpy_common.elements import OptionsFormDefinition
@@ -188,6 +189,8 @@ class ViewAttempt(MessageToWorker):
188189
attempt_state: str
189190
scoring_state: str | None
190191
response: dict | None
192+
uploads: dict[str, list[ResponseFile]] | None
193+
editors: dict[str, EditorData[ResponseFile]] | None
191194

192195
class Response(MessageToServer):
193196
message_id: ClassVar[MessageIds] = MessageIds.RETURN_VIEW_ATTEMPT
@@ -201,6 +204,8 @@ class ScoreAttempt(MessageToWorker):
201204
attempt_state: str
202205
scoring_state: str | None
203206
response: dict
207+
uploads: dict[str, list[ResponseFile]]
208+
editors: dict[str, EditorData[ResponseFile]]
204209

205210
class Response(MessageToServer):
206211
message_id: ClassVar[MessageIds] = MessageIds.RETURN_SCORE_ATTEMPT

0 commit comments

Comments
 (0)