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
39 changes: 37 additions & 2 deletions questionpy_common/api/qtype.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

from abc import abstractmethod
from enum import Enum
from typing import TYPE_CHECKING, Protocol

from questionpy_common.api.package import BasePackageInterface
Expand All @@ -19,6 +20,8 @@
__all__ = [
"InvalidAttemptStateError",
"InvalidQuestionStateError",
"MigrationError",
"MigrationErrorKind",
"OptionsFormValidationError",
"QuestionTypeInterface",
]
Expand Down Expand Up @@ -61,12 +64,24 @@ def create_question_from_state(self, question_state: str) -> QuestionInterface:
InvalidQuestionStateError: When the given question state is invalid and cannot be reused.
"""

@abstractmethod
def upgrade(self, question_state: str) -> str:
"""Upgrade the given question state to the question state version of the main package."""

@abstractmethod
def downgrade(self, question_state: str, to: int) -> str:
"""Downgrade the given question state to the provided question state version of the main package."""

@abstractmethod
def sidegrade(self, question_state: str) -> str:
"""Sidegrade the given question state to the version used by the main package."""


class OptionsFormValidationError(QPyBaseError):
def __init__(self, errors: dict[str, str]):
def __init__(self, errors: dict[str, str], reason: str | None = None, temporary: bool = False): # noqa: FBT001, FBT002
"""There was at least one validation error."""
self.errors = errors # input element name -> error description
super().__init__("Form input data could not be validated successfully.")
super().__init__("Form input data could not be validated successfully.", reason=reason, temporary=temporary)


class InvalidAttemptStateError(QPyBaseError):
Expand All @@ -75,3 +90,23 @@ class InvalidAttemptStateError(QPyBaseError):

class InvalidQuestionStateError(QPyBaseError):
"""Error to raise when your package cannot parse the question state it is given."""


class MigrationErrorKind(Enum):
NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
NOT_POSSIBLE = "NOT_POSSIBLE"
PACKAGE_MISSMATCH = "PACKAGE_MISSMATCH"
QUESTION_STATE_INVALID = "QUESTION_STATE_INVALID"
FAILED = "FAILED"
DISCOVERY_ERROR = "DISCOVERY_ERROR"
OTHER_ERROR = "OTHER_ERROR"


class MigrationError(QPyBaseError):
"""The migration failed."""

def __init__(
self, *args: object, kind: MigrationErrorKind, reason: str | None = None, temporary: bool = False
) -> None:
self.kind = kind
super().__init__(*args, reason=reason, temporary=temporary)
27 changes: 19 additions & 8 deletions questionpy_common/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@
from keyword import iskeyword, issoftkeyword
from typing import Annotated, NewType

from pydantic import BaseModel, ByteSize, PositiveInt, StringConstraints, conset, field_validator
from pydantic import (
AfterValidator,
BaseModel,
ByteSize,
NonNegativeInt,
PositiveInt,
StringConstraints,
conset,
field_validator,
)
from pydantic.fields import Field

from questionpy_common.constants import ENVIRONMENT_VARIABLE_REGEX
Expand Down Expand Up @@ -79,6 +88,10 @@ def ensure_is_valid_name(name: str) -> str:
Bcp47LanguageTag = NewType("Bcp47LanguageTag", str)


type Namespace = Annotated[str, AfterValidator(ensure_is_valid_name)]
type ShortName = Namespace


class PartialPackagePermissions(BaseModel):
cpus: int | None = None
memory: ByteSize | None = None
Expand All @@ -97,8 +110,8 @@ class SourceManifest(BaseModel):
These fields are valid inside a package's configuration file.
"""

short_name: str
namespace: str = DEFAULT_NAMESPACE
short_name: ShortName
namespace: Namespace = DEFAULT_NAMESPACE
version: Annotated[str, Field(pattern=RE_SEMVER)]
api_version: Annotated[str, Field(pattern=RE_API)]
author: str
Expand All @@ -120,11 +133,6 @@ class SourceManifest(BaseModel):
tags: set[str] = set()
requirements: str | list[str] | None = None

@field_validator("short_name", "namespace")
@classmethod
def ensure_is_valid_name(cls, value: str) -> str:
return ensure_is_valid_name(value)

@field_validator("languages", "name")
@classmethod
def ensure_contains_english_translation(
Expand Down Expand Up @@ -170,3 +178,6 @@ class Manifest(SourceManifest):
static_files: dict[str, PackageFile] = {}

dependencies: DistDependencies = DistDependencies()

state_version: NonNegativeInt = 0
possible_side_migrations: dict[Namespace, dict[ShortName, set[NonNegativeInt]]] = {}
35 changes: 21 additions & 14 deletions questionpy_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pydantic import BaseModel, ByteSize, ConfigDict, Field

from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel
from questionpy_common.api.qtype import MigrationErrorKind
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 @@ -84,6 +85,23 @@ class QuestionCreated(QuestionModel):
question_state: str


class QuestionUpgradeArguments(RequestBaseData):
question_state: str


class QuestionDowngradeArguments(RequestBaseData):
question_state: str
to: int


class QuestionSidegradeArguments(RequestBaseData):
question_state: str


class QuestionMigrated(BaseModel):
question_state: str


class LmsProvidedAttributes(BaseModel):
lms_provided_attributes: EnvironmentLmsProvidedAttributes | None = None

Expand Down Expand Up @@ -128,6 +146,7 @@ class RequestErrorCode(Enum):
INVALID_OPTIONS_FORM = "INVALID_OPTIONS_FORM"
PACKAGE_ERROR = "PACKAGE_ERROR"
PACKAGE_NOT_FOUND = "PACKAGE_NOT_FOUND"
MIGRATION_ERROR = "MIGRATION_ERROR"
CALLBACK_API_ERROR = "CALLBACK_API_ERROR"
SERVER_ERROR = "SERVER_ERROR"

Expand All @@ -144,20 +163,8 @@ class OptionsFormValidationError(RequestError):
errors: dict[str, str]


class QuestionStateMigrationErrorCode(Enum):
NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
DOWNGRADE_NOT_POSSIBLE = "DOWNGRADE_NOT_POSSIBLE"
PACKAGE_MISMATCH = "PACKAGE_MISMATCH"
CURRENT_QUESTION_STATE_INVALID = "CURRENT_QUESTION_STATE_INVALID"
MAJOR_VERSION_MISMATCH = "MAJOR_VERSION_MISMATCH"
OTHER_ERROR = "OTHER_ERROR"


class QuestionStateMigrationError(BaseModel):
model_config = ConfigDict(use_enum_values=True)

error_code: QuestionStateMigrationErrorCode
reason: str | None = None
class MigrationError(RequestError):
kind: MigrationErrorKind


class Usage(BaseModel):
Expand Down
49 changes: 43 additions & 6 deletions questionpy_server/web/_routes/_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@
# (c) Technische Universität Berlin, innoCampus <[email protected]>

from aiohttp import web
from aiohttp.web_exceptions import HTTPMethodNotAllowed

from questionpy_common.api.question import LmsPermissions
from questionpy_server.models import QuestionCreateArguments, QuestionEditFormResponse, RequestBaseData
from questionpy_server.models import (
QuestionCreateArguments,
QuestionDowngradeArguments,
QuestionEditFormResponse,
QuestionSidegradeArguments,
QuestionUpgradeArguments,
RequestBaseData,
)
from questionpy_server.package import Package
from questionpy_server.web._decorators import ensure_package, ensure_required_parts
from questionpy_server.web._utils import pydantic_json_response
Expand Down Expand Up @@ -74,10 +80,41 @@ async def post_question(
return pydantic_json_response(data=question)


@package_routes.post(r"/packages/{package_hash:\w+}/question/migrate")
async def post_question_migrate(_request: web.Request) -> web.Response:
method = "POST"
raise HTTPMethodNotAllowed(method, [])
@package_routes.post(r"/packages/{package_hash:\w+}/question/upgrade")
@ensure_required_parts
async def post_question_upgrade(request: web.Request, package: Package, data: QuestionUpgradeArguments) -> web.Response:
async with worker_context(request, package, data) as context:
new_question_state = await context.worker.upgrade_question(
context.request_info,
data.question_state,
)

return pydantic_json_response(data=new_question_state)


@package_routes.post(r"/packages/{package_hash:\w+}/question/downgrade")
@ensure_required_parts
async def post_question_downgrade(
request: web.Request, package: Package, data: QuestionDowngradeArguments
) -> web.Response:
async with worker_context(request, package, data) as context:
new_question_state = await context.worker.downgrade_question(context.request_info, data.question_state, data.to)

return pydantic_json_response(data=new_question_state)


@package_routes.post(r"/packages/{package_hash:\w+}/question/sidegrade")
@ensure_required_parts
async def post_question_sidegrade(
request: web.Request, package: Package, data: QuestionSidegradeArguments
) -> web.Response:
async with worker_context(request, package, data) as context:
new_question_state = await context.worker.sidegrade_question(
context.request_info,
data.question_state,
)

return pydantic_json_response(data=new_question_state)


@package_routes.post(r"/package-extract-info")
Expand Down
15 changes: 15 additions & 0 deletions questionpy_server/web/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from aiohttp import web
from aiohttp.log import web_logger

from questionpy_common.api.qtype import MigrationErrorKind
from questionpy_server.models import MigrationError as MigrationErrorModel
from questionpy_server.models import OptionsFormValidationError, RequestError, RequestErrorCode


Expand Down Expand Up @@ -156,6 +158,19 @@ def __init__(self, *, reason: str | None, temporary: bool) -> None:
)


class MigrationError(web.HTTPUnprocessableEntity, _ExceptionMixin):
def __init__(self, *, reason: str | None, temporary: bool, kind: MigrationErrorKind) -> None:
super().__init__(
"Migration failed or is not possible.",
MigrationErrorModel(
error_code=RequestErrorCode.MIGRATION_ERROR,
temporary=temporary,
reason=reason,
kind=kind,
),
)


class ServerError(web.HTTPInternalServerError):
def __init__(self, *, reason: str | None, temporary: bool) -> None:
body = RequestError(
Expand Down
9 changes: 8 additions & 1 deletion questionpy_server/web/middlewares/_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
from aiohttp.web_response import StreamResponse

import questionpy_server.web.errors as web_error
from questionpy_common.api.qtype import InvalidAttemptStateError, InvalidQuestionStateError, OptionsFormValidationError
from questionpy_common.api.qtype import (
InvalidAttemptStateError,
InvalidQuestionStateError,
MigrationError,
OptionsFormValidationError,
)
from questionpy_common.error import QPyBaseError
from questionpy_server.utils.manifest import ManifestError
from questionpy_server.worker.exception import (
Expand Down Expand Up @@ -49,6 +54,8 @@ async def error_middleware(request: Request, handler: Handler) -> StreamResponse
raise exception(reason=e.reason, temporary=e.temporary) from e
except OptionsFormValidationError as e:
raise web_error.InvalidOptionsFormError(reason=e.reason, errors=e.errors) from e
except MigrationError as e:
raise web_error.MigrationError(reason=e.reason, temporary=e.temporary, kind=e.kind) from e
except Exception as e:
web_logger.exception("There was an unexpected error while processing the request.")
raise web_error.ServerError(reason="unknown", temporary=True) from e
42 changes: 41 additions & 1 deletion questionpy_server/worker/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from questionpy_common.elements import OptionsFormDefinition
from questionpy_common.environment import PackagePermissions, RequestInfo
from questionpy_common.manifest import PackageFile
from questionpy_server.models import LoadedPackage, QuestionCreated
from questionpy_server.models import LoadedPackage, QuestionCreated, QuestionMigrated
from questionpy_server.utils.manifest import ComparableManifest
from questionpy_server.worker.runtime.messages import MessageToServer, MessageToWorker
from questionpy_server.worker.runtime.package_location import PackageLocation
Expand Down Expand Up @@ -151,6 +151,46 @@ async def create_question_from_options(
New question.
"""

@abstractmethod
async def upgrade_question(self, request_info: RequestInfo, question_state: str) -> QuestionMigrated:
"""Upgrade the given question state to the question state version of this package.

Args:
request_info: Information about the current request.
question_state: The question state which should be upgraded.

Returns:
Migrated question state.
"""

@abstractmethod
async def downgrade_question(self, request_info: RequestInfo, question_state: str, to: int) -> QuestionMigrated:
"""Downgrade the given question state to the question state version of this package.

Args:
request_info: Information about the current request.
question_state: The question state with the same question state version as this package which should be
downgraded.
to: The question state version to downgrade to.

Returns:
Migrated question state.
"""

@abstractmethod
async def sidegrade_question(self, request_info: RequestInfo, question_state: str) -> QuestionMigrated:
"""Sidegrade the given question state to the question state version of this package.

The question state can origin from a different package.

Args:
request_info: Information about the current request.
question_state: The question state which should be upgraded.

Returns:
Migrated question state.
"""

@abstractmethod
async def start_attempt(self, request_info: RequestInfo, question_state: str, variant: int) -> AttemptStartedModel:
"""Start an attempt at this question with the given variant.
Expand Down
Loading