Skip to content

Commit

Permalink
feat!: implement server-package API changes
Browse files Browse the repository at this point in the history
  • Loading branch information
MHajoha committed Jun 21, 2024
1 parent 4dda819 commit a9e2c59
Show file tree
Hide file tree
Showing 25 changed files with 398 additions and 228 deletions.
34 changes: 17 additions & 17 deletions poetry.lock

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

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description = "QuestionPy application server"
authors = ["Technische Universität Berlin, innoCampus <[email protected]>"]
license = "MIT"
homepage = "https://questionpy.org"
version = "0.2.5"
version = "0.3.0"
packages = [
{ include = "questionpy_common" },
{ include = "questionpy_server" }
Expand Down
20 changes: 20 additions & 0 deletions questionpy_common/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
# This file is part of QuestionPy. (https://questionpy.org)
# QuestionPy is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <[email protected]>
from collections.abc import (
Mapping,
Sequence, # noqa: F401
)
from typing import (
TypeAlias,
Union, # noqa: F401,
)

from pydantic import BaseModel
from typing_extensions import TypeAliasType


class Localized(BaseModel):
lang: str


# "Regular" recursive type aliases break Pydantic: https://github.com/pydantic/pydantic/issues/8346
PlainValue: TypeAlias = TypeAliasType("PlainValue", "Union[None, int, str, bool, Sequence[PlainValue], PlainMapping]")
PlainMapping: TypeAlias = TypeAliasType("PlainMapping", Mapping[str, PlainValue])
63 changes: 38 additions & 25 deletions questionpy_common/api/attempt.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@
# QuestionPy is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <[email protected]>

from abc import ABC, abstractmethod
from collections.abc import Sequence
from enum import Enum
from typing import Annotated

from pydantic import BaseModel, Field

from . import Localized

__all__ = [
"AttemptFile",
"AttemptModel",
"AttemptScoredModel",
"AttemptStartedModel",
"AttemptUi",
"BaseAttempt",
"CacheControl",
"ClassifiedResponse",
"ScoreModel",
Expand Down Expand Up @@ -46,16 +46,20 @@ class AttemptUi(BaseModel):

placeholders: dict[str, str] = {}
"""Names and values of the ``<?p`` placeholders that appear in content."""
css_files: list[str]
css_files: list[str] = []
files: dict[str, AttemptFile] = {}
cache_control: CacheControl = CacheControl.PRIVATE_CACHE


class AttemptModel(BaseModel):
class AttemptModel(Localized):
variant: int
ui: AttemptUi


class AttemptStartedModel(AttemptModel):
attempt_state: str


class ScoringCode(Enum):
AUTOMATICALLY_SCORED = "AUTOMATICALLY_SCORED"
NEEDS_MANUAL_SCORING = "NEEDS_MANUAL_SCORING"
Expand All @@ -70,31 +74,40 @@ class ClassifiedResponse(BaseModel):
score: float


class ScoredInputState(Enum):
CORRECT = "CORRECT"
CONSEQUENTIAL_SCORE = "CONSEQUENTIAL_SCORE"
PARTIALLY_CORRECT = "PARTIALLY_CORRECT"
WRONG = "WRONG"


class ScoredInputModel(BaseModel):
state: ScoredInputState
score: float | None = None
score_max: float | None = None
specific_feedback: str | None = None
right_answer: str | None = None


class ScoredSubquestionModel(BaseModel):
score: float | None = None
score_final: float | None = None
scoring_code: ScoringCode | None = None
response_summary: str
response_class: str


class ScoreModel(BaseModel):
scoring_state: str = "{}"
scoring_state: str | None = None
scoring_code: ScoringCode
score: float | None
"""The score for this question attempt, must lie between the `score_min` and `score_max` set by the question."""
classification: Sequence[ClassifiedResponse] | None = None
score_final: float | None
scored_inputs: dict[str, ScoredInputModel] = {}
"""Maps input names to their individual scores."""
scored_subquestions: dict[str, ScoredSubquestionModel] = {}
"""Maps subquestion IDs to their individual scores."""


class AttemptScoredModel(AttemptModel, ScoreModel):
pass


class BaseAttempt(ABC):
@abstractmethod
def export_attempt_state(self) -> str:
"""Serialize this attempt's relevant data.
A future call to :meth:`BaseQuestion.view_attempt` should result in an attempt object identical to the one which
exported the state.
"""

@abstractmethod
def export(self) -> AttemptModel:
"""Get metadata about this attempt."""

@abstractmethod
def export_scored_attempt(self) -> AttemptScoredModel:
"""Score this attempt."""
23 changes: 23 additions & 0 deletions questionpy_common/api/package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# This file is part of QuestionPy. (https://questionpy.org)
# QuestionPy is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <[email protected]>
from abc import abstractmethod
from typing import TYPE_CHECKING, Protocol, TypeAlias

from questionpy_common.manifest import PackageFile

if TYPE_CHECKING:
from questionpy_common.api.qtype import QuestionTypeInterface


class BasePackageInterface(Protocol):
@abstractmethod
def get_static_files(self) -> dict[str, PackageFile]:
pass


class LibraryPackageInterface(BasePackageInterface, Protocol):
pass


QPyPackageInterface: TypeAlias = "LibraryPackageInterface | QuestionTypeInterface"
22 changes: 14 additions & 8 deletions questionpy_common/api/qtype.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
# This file is part of QuestionPy. (https://questionpy.org)
# QuestionPy is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <[email protected]>
from __future__ import annotations

from abc import ABC, abstractmethod
from abc import abstractmethod
from typing import TYPE_CHECKING, Protocol

from questionpy_common.elements import OptionsFormDefinition
from questionpy_common.api.package import BasePackageInterface

from .question import BaseQuestion
if TYPE_CHECKING:
from questionpy_common.elements import OptionsFormDefinition

__all__ = ["BaseQuestionType", "InvalidQuestionStateError", "OptionsFormValidationError"]
from . import PlainMapping
from .question import QuestionInterface

__all__ = ["InvalidQuestionStateError", "OptionsFormValidationError", "QuestionTypeInterface"]

class BaseQuestionType(ABC):

class QuestionTypeInterface(BasePackageInterface, Protocol):
@abstractmethod
def get_options_form(self, question_state: str | None) -> tuple[OptionsFormDefinition, dict[str, object]]:
def get_options_form(self, question_state: str | None) -> tuple[OptionsFormDefinition, PlainMapping]:
"""Get the form used to create a new or edit an existing question.
Args:
Expand All @@ -24,7 +30,7 @@ def get_options_form(self, question_state: str | None) -> tuple[OptionsFormDefin
"""

@abstractmethod
def create_question_from_options(self, old_state: str | None, form_data: dict[str, object]) -> BaseQuestion:
def create_question_from_options(self, old_state: str | None, form_data: PlainMapping) -> QuestionInterface:
"""Create or update the question (state) with the form data from a submitted question edit form.
Args:
Expand All @@ -39,7 +45,7 @@ def create_question_from_options(self, old_state: str | None, form_data: dict[st
"""

@abstractmethod
def create_question_from_state(self, question_state: str) -> BaseQuestion:
def create_question_from_state(self, question_state: str) -> QuestionInterface:
"""Deserialize the given question state, returning a question object equivalent to the one which exported it.
Raises:
Expand Down
Loading

0 comments on commit a9e2c59

Please sign in to comment.