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
11 changes: 8 additions & 3 deletions docs/qppe-lms.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
74 changes: 71 additions & 3 deletions docs/qppe-server.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -1066,7 +1134,7 @@ components:
nullable: true
default: null
scoring_code:
type: string
type: [string, "null"]
enum:
- AUTOMATICALLY_SCORED
- NEEDS_MANUAL_SCORING
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "[email protected]" }
Expand Down
54 changes: 54 additions & 0 deletions questionpy_common/api/files.py
Original file line number Diff line number Diff line change
@@ -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."""
25 changes: 20 additions & 5 deletions questionpy_common/api/question.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"SubquestionModel",
]

from .files import EditorData, ResponseFile


class ScoringMethod(Enum):
ALWAYS_MANUAL_SCORING_REQUIRED = "ALWAYS_MANUAL_SCORING_REQUIRED"
Expand Down Expand Up @@ -68,23 +70,33 @@ 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.

Args:
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,
Expand All @@ -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
"""
Expand Down
13 changes: 9 additions & 4 deletions questionpy_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
# (c) Technische Universität Berlin, innoCampus <[email protected]>

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
Expand Down Expand Up @@ -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


Expand Down
4 changes: 4 additions & 0 deletions questionpy_server/web/_routes/_attempts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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()

Expand Down
26 changes: 23 additions & 3 deletions questionpy_server/worker/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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.
Expand All @@ -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:
Expand Down
Loading