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
1 change: 1 addition & 0 deletions frontend/src/components/common/error/ErrorDisplay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

<template>
<h5>{{ fetchError.message }} ({{ fetchError.status }}: {{ fetchError.statusText }})</h5>
<h6>Stacktrace</h6>
<code v-if="typeof fetchError.details === 'string'">
<pre>{{ fetchError.details }}</pre>
</code>
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/components/common/error/InvalidQuestionStateError.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!--
This file is part of the QuestionPy SDK. (https://questionpy.org)
The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md.
(c) Technische Universität Berlin, innoCampus <[email protected]>
-->

<template>
<BAlert :model-value="true" variant="warning">
The package could not parse one or more question states. If you renamed or added required fields, delete the
existing states and create a new question. Otherwise, this may be a bug in the package.
<ButtonGroup>
<IconButton @click="deleteQuestionStates" :icon-component="IMdiDelete" variant="danger" size="sm"
>Delete question states</IconButton
></ButtonGroup
>
</BAlert>
</template>

<script lang="ts" setup>
import IMdiDelete from '~icons/mdi/delete'

import { useDeleteAllQuestions } from '@/composables/question'

const deleteQuestionStates = useDeleteAllQuestions()
</script>
19 changes: 14 additions & 5 deletions frontend/src/components/question/QuestionCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@
<BCard :variant="cardVariant">
<BCardText>
<!-- TODO: Show Question -->
<pre>{{ formData }}</pre>
<CollapsibleCard v-if="isDetailedServerError(error)" variant="danger">
<template #button-title>{{ error.error }}</template>
<p>The package could not parse the question state.</p>
<code>
<pre>{{ error.details }}</pre>
</code>
</CollapsibleCard>
<pre v-if="data">{{ data }}</pre>
</BCardText>
<ButtonGroup>
<!-- TODO: Implement clone -->
Expand Down Expand Up @@ -47,13 +54,15 @@ import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useLink } from 'vue-router'

import useDeleteQuestion from '@/composables/question/useDeleteQuestion'
import { useDeleteQuestion } from '@/composables/question'
import useAppStateStore from '@/stores/useAppStateStore'
import type { OptionsFormData } from '@/types'
import { isDetailedServerError } from '@/types'
import type { DetailedServerError, OptionsFormData } from '@/types'

const { questionId } = defineProps<{
const { data, questionId } = defineProps<{
questionId: string
formData: OptionsFormData
data?: OptionsFormData
error?: DetailedServerError
}>()

const questionLocation = { name: 'question', params: { questionId } } as const
Expand Down
21 changes: 18 additions & 3 deletions frontend/src/components/question/QuestionList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
<ErrorCard v-if="error" :error="error" />
<CollapsibleCard v-else expanded>
<template #button-title>Saved questions ({{ questionCount }})</template>
<InvalidQuestionStateError v-if="hasInvalidStates" class="mb-3" />
<QuestionCard
v-for="[questionId, formData] in Object.entries(questions)"
v-for="[questionId, question] in Object.entries(questions)"
class="question-card"
:id="`question-${questionId}`"
:key="questionId"
:questionId="questionId"
:formData="formData"
:data="question.data"
:error="question.error"
/>
<BAlert v-if="questionCount === 0" :model-value="true" class="mb-0" variant="info"
>This package has no questions yet.</BAlert
Expand All @@ -27,11 +29,24 @@
import { computed } from 'vue'

import { useQuestionStatesQuery } from '@/queries'
import { isDetailedServerError } from '@/types'
import type { DetailedServerError, OptionsFormData } from '@/types'

const { asyncStatus, error, data } = useQuestionStatesQuery()

const questions = computed(() => data.value ?? {})
const questions = computed<Record<string, { data?: OptionsFormData; error?: DetailedServerError }>>(() => {
if (data.value === undefined) {
return {}
}
const entries = Object.entries(data.value).map(([questionId, value]) => [
questionId,
isDetailedServerError(value) ? { data: undefined, error: value } : { data: value, error: undefined },
])
return Object.fromEntries(entries)
})

const questionCount = computed(() => Object.keys(questions.value).length)
const hasInvalidStates = computed(() => Object.values(questions.value).some((q) => q.error))
</script>

<style lang="scss" scoped>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/composables/question/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@
* (c) Technische Universität Berlin, innoCampus <[email protected]>
*/

export { default as useCreateQuestion } from './useCreateQuestion'
export { default as useDeleteAllQuestions } from './useDeleteAllQuestions'
export { default as useDeleteQuestion } from './useDeleteQuestion'
export { default as useFormDataState } from './useFormDataState'
export { default as useRepetitions } from './useRepetitions'
45 changes: 45 additions & 0 deletions frontend/src/composables/question/useDeleteAllQuestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* This file is part of the QuestionPy SDK. (https://questionpy.org)
* The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md.
* (c) Technische Universität Berlin, innoCampus <[email protected]>
*/

import { useRouter } from 'vue-router'

import { useConfirmModal } from '@/composables/common'
import { useDeleteAllOptionsFormDataMutation } from '@/queries'
import useAppStateStore from '@/stores/useAppStateStore'

/**
* Composable that returns a function to delete all question states after displaying a confirmation modal.
*
* @returns A function that, when called, shows a confirmation modal and deletes all questions if confirmed.
*/
function useDeleteAllQuestions() {
const router = useRouter()
const { mutateAsync } = useDeleteAllOptionsFormDataMutation()
const { setError } = useAppStateStore()
const confirmModal = useConfirmModal({
title: 'Delete All Questions',
body: 'Are you sure you want to delete all question states?',
okTitle: 'Delete All Questions',
})

return async () => {
if (await confirmModal()) {
try {
await mutateAsync()
} catch (err) {
setError(err)
return
}

// Next, navigate to the index page, because the old route may not exist anymore
if (router.currentRoute.value.name !== 'index') {
await router.push({ name: 'index' })
}
}
}
}

export default useDeleteAllQuestions
2 changes: 1 addition & 1 deletion frontend/src/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import IMdiAdd from '~icons/mdi/add'
import IMdiImport from '~icons/mdi/import'

import useCreateQuestion from '@/composables/question/useCreateQuestion'
import { useCreateQuestion } from '@/composables/question'

const createQuestion = useCreateQuestion()

Expand Down
15 changes: 11 additions & 4 deletions frontend/src/pages/question/[questionId]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<IconButton :to="{ name: 'index' }" :icon-component="IMdiArrowLeft" class="ps-0 mb-2" variant="link"
>Back to Package Preview</IconButton
>
<QuestionCard class="mb-3" :question-id="params.questionId" :form-data="formData?.data ?? {}" />
<QuestionCard class="mb-3" :question-id="params.questionId" :data="formData?.data" :error="detailedServerError" />
<ButtonGroup class="mb-4">
<IconButton :icon-component="IMdiImport" variant="link" @click="importAttempt">Import attempt</IconButton>
<IconButton :icon-component="IMdiAdd" @click="createAttempt" variant="primary">New attempt</IconButton>
Expand All @@ -20,15 +20,16 @@
import IMdiAdd from '~icons/mdi/add'
import IMdiArrowLeft from '~icons/mdi/arrow-left'
import IMdiImport from '~icons/mdi/import'
import { watch } from 'vue'
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'

import { useCreateAttempt } from '@/composables/attempt'
import { useOptionsFormDataQuery } from '@/queries'
import { FetchError, useOptionsFormDataQuery } from '@/queries'
import type { DetailedServerError } from '@/types'

const route = useRouter()
const { params } = useRoute('question')
const { data: formData } = useOptionsFormDataQuery(params.questionId)
const { data: formData, error } = useOptionsFormDataQuery(params.questionId)
const createAttempt = useCreateAttempt(params.questionId)

// If question doesn't exist, show index instead
Expand All @@ -45,6 +46,12 @@ watch(
function importAttempt() {
// TODO: import question
}

const detailedServerError = computed(() =>
error.value instanceof FetchError && error.value.message === 'InvalidQuestionStateError'
? ({ error: error.value.message, details: error.value.details } satisfies DetailedServerError)
: undefined,
)
</script>

<route lang="json">
Expand Down
1 change: 1 addition & 0 deletions frontend/src/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {
export { FetchError } from './fetch'
export { useManifestQuery } from './package'
export {
useDeleteAllOptionsFormDataMutation,
useDeleteOptionsFormDataMutation,
useOptionsFormDataQuery,
useOptionsFormDefinitionQuery,
Expand Down
31 changes: 29 additions & 2 deletions frontend/src/queries/question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@

import { useMutation, useQuery, useQueryCache } from '@pinia/colada'

import type { OptionsFormData, OptionsFormDefinition, OptionsStateResponse, ServerValidationErrors } from '@/types'
import type {
DetailedServerError,
OptionsFormData,
OptionsFormDefinition,
OptionsStateResponse,
ServerValidationErrors,
} from '@/types'

import { delete_, get, post } from './fetch'
import QUERY_KEYS from './queryKeys'
Expand All @@ -19,9 +25,29 @@ import QUERY_KEYS from './queryKeys'
const useQuestionStatesQuery = () =>
useQuery({
key: QUERY_KEYS.question.list(),
query: () => get<Record<string, OptionsFormData>>('questions'),
query: () => get<Record<string, OptionsFormData | DetailedServerError>>('questions'),
})

/**
* Delete all question options form data.
*
* @returns A mutation return object.
*/
function useDeleteAllOptionsFormDataMutation() {
const { invalidateQueries } = useQueryCache()

const invalidateKeys = [QUERY_KEYS.question.root, QUERY_KEYS.attempt.root]

return useMutation({
mutation: () => delete_('questions'),
onSettled: () => {
for (const key of invalidateKeys) {
invalidateQueries({ key })
}
},
})
}

/**
* Get question form data by question ID.
*
Expand Down Expand Up @@ -98,6 +124,7 @@ function useDeleteOptionsFormDataMutation(questionId: string) {
}

export {
useDeleteAllOptionsFormDataMutation,
useDeleteOptionsFormDataMutation,
useOptionsFormDataQuery,
useOptionsFormDefinitionQuery,
Expand Down
2 changes: 1 addition & 1 deletion generate-ts-types.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from questionpy_sdk.webserver.controllers.attempt.errors import ErrorSectionKey
from questionpy_sdk.webserver.controllers.attempt.question_ui import ClientQuestionDisplayOptions
from questionpy_sdk.webserver.controllers.question.controller import OptionsStateResponse
from questionpy_sdk.webserver.middlewares.error import DetailedServerError
from questionpy_sdk.webserver.errors import DetailedServerError

logging.basicConfig(level=logging.INFO, format="")
logger = logging.getLogger(__name__)
Expand Down
8 changes: 6 additions & 2 deletions questionpy_sdk/webserver/controllers/attempt/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ async def get_attempt(
data: dict[str, JsonValue] | None = None
state: str | None = None
score: ScoreModel | None = None
new_seed: bool = False

with contextlib.suppress(webserver_errors.MissingAttemptDataError):
data = await self._state_manager.read_attempt_data(question_id, attempt_id)
Expand All @@ -47,12 +48,15 @@ async def get_attempt(
seed = await self._state_manager.read_attempt_seed(question_id, attempt_id)
except webserver_errors.MissingAttemptSeedError:
seed = random.randint(0, 1000)
await self._state_manager.write_attempt_seed(question_id, attempt_id, seed)
new_seed = True

async with self.get_worker() as worker:
attempt, state = await self._get_or_start_attempt(question_id, attempt_id, state, data, score, worker)
renderer = _AttemptRenderer(attempt, display_options, self.generate_api_url, worker)
return await renderer.render_ui(data, state, score, seed)
render_data = await renderer.render_ui(data, state, score, seed)
if new_seed:
await self._state_manager.write_attempt_seed(question_id, attempt_id, seed)
return render_data

async def get_attempts(self, question_id: str) -> dict[str, AttemptData]:
"""Gets all saved attempts."""
Expand Down
21 changes: 15 additions & 6 deletions questionpy_sdk/webserver/controllers/question/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
from pydantic import ConfigDict
from pydantic.dataclasses import dataclass

from questionpy_common.api.qtype import InvalidQuestionStateError
from questionpy_common.elements import OptionsFormDefinition
from questionpy_sdk.webserver.constants import DEFAULT_REQUEST_INFO
from questionpy_sdk.webserver.controllers.base import BaseController
from questionpy_sdk.webserver.controllers.question._form_data import OptionsFormData, flatten_form_data, parse_form_data
from questionpy_sdk.webserver.errors import MissingQuestionStateError
from questionpy_sdk.webserver.errors import DetailedServerError, MissingQuestionStateError, format_error


@dataclass(config=ConfigDict(use_attribute_docstrings=True))
Expand All @@ -35,17 +36,22 @@ async def get_form_definition(self, question_id: str) -> OptionsFormDefinition:

return form_definition

async def get_questions(self) -> dict[str, OptionsFormData]:
async def get_questions(self) -> dict[str, OptionsFormData | DetailedServerError]:
states_str = await self._state_manager.read_question_states()
states: dict[str, OptionsFormData] = {}
states: dict[str, OptionsFormData | DetailedServerError] = {}

if len(states_str) > 0:
async with self.get_worker() as worker:
for question_id in states_str:
state = states_str[question_id]
form_definition, form_data = await worker.get_options_form(DEFAULT_REQUEST_INFO, state)
flat_form_data = flatten_form_data(form_data, self._section_names_from_definition(form_definition))
states[question_id] = flat_form_data
try:
form_definition, form_data = await worker.get_options_form(DEFAULT_REQUEST_INFO, state)
except InvalidQuestionStateError as err:
states[question_id] = DetailedServerError(type(err).__name__, format_error(err))
else:
section_names = self._section_names_from_definition(form_definition)
flat_form_data = flatten_form_data(form_data, section_names)
states[question_id] = flat_form_data

return states

Expand Down Expand Up @@ -81,6 +87,9 @@ async def save_options_state(self, question_id: str, data: OptionsFormData) -> N
async def delete_question(self, question_id: str) -> None:
await self._state_manager.delete_question(question_id)

async def delete_all_questions(self) -> None:
await self._state_manager.delete_all_questions()

@staticmethod
def _section_names_from_definition(form_definition: OptionsFormDefinition) -> list[str]:
return [section.name for section in form_definition.sections]
Loading