diff --git a/docs/qppe-lms.yaml b/docs/qppe-lms.yaml index e0ae11ce..acf2c03d 100644 --- a/docs/qppe-lms.yaml +++ b/docs/qppe-lms.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.3 +openapi: 3.1.1 info: title: Question Provider and Package Execution (QPPE) LMS Callback API version: 0.1.0 @@ -267,8 +267,7 @@ components: description: A unique file identifier that is determined by the LMS. It might be a file hash or some other identifier that changes when the content is modified. schema: - type: string - pattern: '^[a-zA-Z0-9\-_=]{1,64}$' + $ref: '#/components/schemas/FileRef' FileName: name: file_name @@ -292,6 +291,12 @@ components: description: A question attempt reference that is determined by the LMS. It uniquely identifies an attempt of a question. + FileRef: + type: string + pattern: '^[a-zA-Z0-9\-_=]{1,64}$' + description: A unique file identifier that is determined by the LMS. It might be a file hash or some other + identifier that changes when the content is modified. + AttemptAsyncScoringStatusFinished: type: object allOf: diff --git a/docs/qppe-server.yaml b/docs/qppe-server.yaml index 3df43386..ebf037b1 100644 --- a/docs/qppe-server.yaml +++ b/docs/qppe-server.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.3 +openapi: 3.1.1 info: title: Question Provider and Package Execution (QPPE) Server API version: 0.1.0 @@ -974,8 +974,62 @@ components: nullable: true default: null description: Data from the question's input fields. + uploads: + type: object + nullable: true + default: null + description: The files uploaded to any file upload elements, by their names. + additionalProperties: + type: array + items: + $ref: '#/components/schemas/UserUploadedFile' + editors: + type: object + nullable: true + default: null + description: The text entered and files uploaded into rich text editor elements, by their names. + additionalProperties: + $ref: '#/components/schemas/RichTextEditorData' required: [ attempt_ref, attempt_state, response_ref ] + UserUploadedFile: + description: A file that is stored by the LMS. Contains metadata and a reference to retrieve the file. + type: object + properties: + path: + type: string + pattern: "^/(.+/)?$" + description: The folder path of this file. Must begin and end in `/`. Top-level files have a path of `/`. + filename: + type: string + file_ref: + $ref: "qppe-lms.yaml#/components/schemas/FileRef" + uploaded_at: + type: string + format: date-time + mime_type: + type: string + format: mime-type + size: + type: integer + required: [ path, filename, file_ref, uploaded_at, mime_type, size ] + + RichTextEditorData: + description: The structure submitted by rich text editors. + type: object + properties: + text: + type: string + files: + type: array + items: + $ref: "#/components/schemas/UserUploadedFile" + default: [] + patternProperties: + "^_": + description: The LMS may send and expect to receive again any additional fields beginning with `_`. + required: [ text ] + AttemptScoreArguments: allOf: - $ref: "#/components/schemas/AttemptViewArguments" @@ -984,6 +1038,20 @@ components: response: type: object additionalProperties: true + uploads: + type: object + default: {} + description: The files uploaded to any file upload elements, by their names. + additionalProperties: + type: array + items: + $ref: '#/components/schemas/UserUploadedFile' + editors: + type: object + default: {} + description: The text entered and files uploaded into rich text editor elements, by their names. + additionalProperties: + $ref: '#/components/schemas/RichTextEditorData' timeout: type: number minimum: 0 @@ -999,7 +1067,7 @@ components: generate_hint: type: boolean description: Try to give a hint on how to improve the response. - required: [ response, timeout, compute_adjusted_score, generate_hint ] + required: [ response, uploads, editors, timeout, compute_adjusted_score, generate_hint ] AttemptScored: allOf: @@ -1066,7 +1134,7 @@ components: nullable: true default: null scoring_code: - type: string + type: [string, "null"] enum: - AUTOMATICALLY_SCORED - NEEDS_MANUAL_SCORING diff --git a/pyproject.toml b/pyproject.toml index 63e37224..1427fe2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "questionpy-server" description = "QuestionPy application server" license = { file = "LICENSE.md" } urls = { homepage = "https://questionpy.org" } -version = "0.8.0" +version = "0.9.0" authors = [ { name = "TU Berlin innoCampus" }, { email = "info@isis.tu-berlin.de" } diff --git a/questionpy_common/api/files.py b/questionpy_common/api/files.py new file mode 100644 index 00000000..4acc82d6 --- /dev/null +++ b/questionpy_common/api/files.py @@ -0,0 +1,54 @@ +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Annotated + +from pydantic import BaseModel, ByteSize, ConfigDict, StringConstraints + + +class UserUploadedFile(BaseModel, ABC): + """Metadata about a file uploaded by a user to the LMS.""" + + path: Annotated[str, StringConstraints(pattern=r"^/(.+/)?$")] + """The folder path of this file. 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 + @abstractmethod + def uri(self) -> str: + """Builds a URI that can be used to embed the file in a question.""" + + +class ResponseFile(UserUploadedFile): + @property + def uri(self) -> str: + return f"qpy://response/{self.file_ref}" + + +class OptionsFile(UserUploadedFile): + @property + def uri(self) -> str: + return f"qpy://options/{self.file_ref}" + + +class EditorData[FileT: UserUploadedFile](BaseModel): + """Data submitted by rich text editors.""" + + # 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[FileT] = [] + """Files referenced by the markup.""" diff --git a/questionpy_common/api/question.py b/questionpy_common/api/question.py index a14c2472..b1b35acd 100644 --- a/questionpy_common/api/question.py +++ b/questionpy_common/api/question.py @@ -19,6 +19,8 @@ "SubquestionModel", ] +from .files import EditorData, ResponseFile + class ScoringMethod(Enum): ALWAYS_MANUAL_SCORING_REQUIRED = "ALWAYS_MANUAL_SCORING_REQUIRED" @@ -68,7 +70,12 @@ def start_attempt(self, variant: int) -> AttemptStartedModel: @abstractmethod 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] | None = None, ) -> AttemptModel: """Create an attempt object for a previously started attempt. @@ -76,15 +83,20 @@ def get_attempt( attempt_state: The `attempt_state` attribute of an attempt which was previously returned by [start_attempt][]. scoring_state: Not implemented. - response: The response currently entered by the student. + response: The response currently entered by the student, excluding uploads and editors. + uploads: Files uploaded by the student in file upload elements (files belonging to editors are passed in + `editors`.) + editors: WYSIWYG editor responses entered by the student. """ @abstractmethod 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], *, compute_adjusted_score: bool = False, generate_hint: bool = False, @@ -95,7 +107,10 @@ def score_attempt( attempt_state: The `attempt_state` attribute of an attempt which was previously returned by [start_attempt][]. scoring_state: Not implemented. - response: The response currently entered by the student. + response: The response currently entered by the student, excluding uploads and editors. + uploads: Files uploaded by the student in file upload elements (files belonging to editors are passed in + `editors`.) + editors: WYSIWYG editor responses entered by the student. compute_adjusted_score: TBD generate_hint: TBD """ diff --git a/questionpy_server/models.py b/questionpy_server/models.py index 6746d208..63520d8e 100644 --- a/questionpy_server/models.py +++ b/questionpy_server/models.py @@ -3,11 +3,12 @@ # (c) Technische Universität Berlin, innoCampus from enum import Enum -from typing import Annotated, Any +from typing import Annotated -from pydantic import BaseModel, ByteSize, ConfigDict, Field +from pydantic import BaseModel, ByteSize, ConfigDict, Field, JsonValue from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel +from questionpy_common.api.files import EditorData, ResponseFile from questionpy_common.api.question import LmsPermissions, QuestionModel from questionpy_common.elements import OptionsFormDefinition from questionpy_common.environment import LmsProvidedAttributes as EnvironmentLmsProvidedAttributes @@ -95,11 +96,15 @@ class AttemptStartArguments(RequestBaseData, LmsProvidedAttributes): class AttemptViewArguments(RequestBaseData, LmsProvidedAttributes): attempt_state: str scoring_state: str | None = None - response: dict[str, Any] | None = None + response: dict[str, JsonValue] | None = None + uploads: dict[str, list[ResponseFile]] | None = None + editors: dict[str, EditorData[ResponseFile]] | None = None class AttemptScoreArguments(AttemptViewArguments): - response: dict[str, Any] + response: dict[str, JsonValue] + uploads: dict[str, list[ResponseFile]] = {} + editors: dict[str, EditorData[ResponseFile]] = {} generate_hint: bool diff --git a/questionpy_server/web/_routes/_attempts.py b/questionpy_server/web/_routes/_attempts.py index 58dbcb7f..d6b24354 100644 --- a/questionpy_server/web/_routes/_attempts.py +++ b/questionpy_server/web/_routes/_attempts.py @@ -44,6 +44,8 @@ async def post_attempt_view( attempt_state=data.attempt_state, scoring_state=data.scoring_state, response=data.response, + uploads=data.uploads, + editors=data.editors, ) packages = context.worker.get_loaded_packages() @@ -63,6 +65,8 @@ async def post_attempt_score( attempt_state=data.attempt_state, scoring_state=data.scoring_state, response=data.response, + uploads=data.uploads, + editors=data.editors, ) packages = context.worker.get_loaded_packages() diff --git a/questionpy_server/worker/__init__.py b/questionpy_server/worker/__init__.py index ece7f186..0b1d642d 100644 --- a/questionpy_server/worker/__init__.py +++ b/questionpy_server/worker/__init__.py @@ -10,6 +10,7 @@ from pydantic import BaseModel from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel +from questionpy_common.api.files import EditorData, ResponseFile from questionpy_common.api.question import LmsPermissions from questionpy_common.elements import OptionsFormDefinition from questionpy_common.environment import PackagePermissions, RequestInfo @@ -173,6 +174,8 @@ async def get_attempt( attempt_state: str, scoring_state: str | None = None, response: dict | None = None, + uploads: dict[str, list[ResponseFile]] | None = None, + editors: dict[str, EditorData[ResponseFile]] | None = None, ) -> AttemptModel: """Create an attempt object for a previously started attempt. @@ -181,8 +184,11 @@ async def get_attempt( question_state: The question the attempt belongs to. attempt_state: The `attempt_state` attribute of an attempt which was previously returned by :meth:`start_attempt`. - scoring_state: Not implemented. - response: The response currently entered by the student. + scoring_state: The scoring state returned by the package the last time this attempt was scored. + response: The response by the student to primitive fields and the dynamic `data` object. + uploads: Files uploaded by the student in file upload elements (files belonging to editors are passed in + `editors`.) + editors: WYSIWYG editor responses entered by the student. Returns: Metadata of the attempt. @@ -197,8 +203,22 @@ async def score_attempt( attempt_state: str, scoring_state: str | None = None, response: dict, + uploads: dict[str, list[ResponseFile]], + editors: dict[str, EditorData[ResponseFile]], ) -> AttemptScoredModel: - """TODO: write docstring.""" + """Score the given response in this attempt. + + Args: + request_info: Information about the current request. + question_state: The question the attempt belongs to. + attempt_state: The `attempt_state` attribute of an attempt which was previously returned by + :meth:`start_attempt`. + scoring_state: The scoring state returned by the package the last time this attempt was scored. + response: The response by the student to primitive fields and the dynamic `data` object. + uploads: Files uploaded by the student in file upload elements (files belonging to editors are passed in + `editors`.) + editors: WYSIWYG editor responses entered by the student. + """ @abstractmethod async def get_static_file(self, path: str) -> PackageFileData: diff --git a/questionpy_server/worker/impl/_base.py b/questionpy_server/worker/impl/_base.py index 38660703..002519e3 100644 --- a/questionpy_server/worker/impl/_base.py +++ b/questionpy_server/worker/impl/_base.py @@ -15,6 +15,7 @@ from zipfile import ZipFile from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel +from questionpy_common.api.files import EditorData, ResponseFile from questionpy_common.api.question import LmsPermissions from questionpy_common.constants import DIST_DIR from questionpy_common.elements import OptionsFormDefinition @@ -264,12 +265,16 @@ async def get_attempt( attempt_state: str, scoring_state: str | None = None, response: dict | None = None, + uploads: dict[str, list[ResponseFile]] | None = None, + editors: dict[str, EditorData[ResponseFile]] | None = None, ) -> AttemptModel: msg = ViewAttempt( question_state=question_state, attempt_state=attempt_state, scoring_state=scoring_state, response=response, + uploads=uploads, + editors=editors, request_info=request_info, ) ret = await self.send_and_wait_for_response(msg, ViewAttempt.Response) @@ -284,12 +289,16 @@ async def score_attempt( attempt_state: str, scoring_state: str | None = None, response: dict, + uploads: dict[str, list[ResponseFile]], + editors: dict[str, EditorData[ResponseFile]], ) -> AttemptScoredModel: msg = ScoreAttempt( question_state=question_state, attempt_state=attempt_state, scoring_state=scoring_state, response=response, + uploads=uploads, + editors=editors, request_info=request_info, ) ret = await self.send_and_wait_for_response(msg, ScoreAttempt.Response) diff --git a/questionpy_server/worker/runtime/manager.py b/questionpy_server/worker/runtime/manager.py index 58b99b21..9d0dc474 100644 --- a/questionpy_server/worker/runtime/manager.py +++ b/questionpy_server/worker/runtime/manager.py @@ -274,7 +274,9 @@ def on_msg_view_attempt(self, msg: ViewAttempt) -> ViewAttempt.Response: with self._with_request_info(msg, msg.request_info): question = self._question_type.create_question_from_state(msg.question_state) - attempt_model = question.get_attempt(msg.attempt_state, msg.scoring_state, msg.response) + attempt_model = question.get_attempt( + msg.attempt_state, msg.scoring_state, msg.response, msg.uploads, msg.editors + ) return ViewAttempt.Response(attempt_model=attempt_model) def on_msg_score_attempt(self, msg: ScoreAttempt) -> ScoreAttempt.Response: @@ -285,7 +287,9 @@ def on_msg_score_attempt(self, msg: ScoreAttempt) -> ScoreAttempt.Response: with self._with_request_info(msg, msg.request_info): question = self._question_type.create_question_from_state(msg.question_state) - attempt_scored_model = question.score_attempt(msg.attempt_state, msg.scoring_state, msg.response) + attempt_scored_model = question.score_attempt( + msg.attempt_state, msg.scoring_state, msg.response, msg.uploads, msg.editors + ) return ScoreAttempt.Response(attempt_scored_model=attempt_scored_model) @staticmethod diff --git a/questionpy_server/worker/runtime/messages.py b/questionpy_server/worker/runtime/messages.py index e4447b8b..2f2e260b 100644 --- a/questionpy_server/worker/runtime/messages.py +++ b/questionpy_server/worker/runtime/messages.py @@ -11,6 +11,7 @@ from pydantic import BaseModel, JsonValue from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel +from questionpy_common.api.files import EditorData, ResponseFile from questionpy_common.api.qtype import InvalidQuestionStateError, OptionsFormValidationError from questionpy_common.api.question import QuestionModel from questionpy_common.elements import OptionsFormDefinition @@ -188,6 +189,8 @@ class ViewAttempt(MessageToWorker): attempt_state: str scoring_state: str | None response: dict | None + uploads: dict[str, list[ResponseFile]] | None + editors: dict[str, EditorData[ResponseFile]] | None class Response(MessageToServer): message_id: ClassVar[MessageIds] = MessageIds.RETURN_VIEW_ATTEMPT @@ -201,6 +204,8 @@ class ScoreAttempt(MessageToWorker): attempt_state: str scoring_state: str | None response: dict + uploads: dict[str, list[ResponseFile]] + editors: dict[str, EditorData[ResponseFile]] class Response(MessageToServer): message_id: ClassVar[MessageIds] = MessageIds.RETURN_SCORE_ATTEMPT