From adbccdaecfe6f706237294808ce3d0db33c7ac53 Mon Sep 17 00:00:00 2001 From: Mirko Dietrich Date: Thu, 16 Oct 2025 14:32:25 +0200 Subject: [PATCH 01/11] feat(webserver): implement attempt cloning Ref: #238 --- .../controllers/attempt/controller.py | 25 +++++++++++ questionpy_sdk/webserver/routes/attempt.py | 15 +++++++ .../controllers/attempt/test_controller.py | 41 +++++++++++++++++++ .../webserver/routes/test_attempt.py | 7 ++++ 4 files changed, 88 insertions(+) diff --git a/questionpy_sdk/webserver/controllers/attempt/controller.py b/questionpy_sdk/webserver/controllers/attempt/controller.py index 5f4c2322..85d304f0 100644 --- a/questionpy_sdk/webserver/controllers/attempt/controller.py +++ b/questionpy_sdk/webserver/controllers/attempt/controller.py @@ -99,6 +99,31 @@ async def score_attempt(self, question_id: str, attempt_id: str) -> None: await self._state_manager.write_attempt_score(question_id, attempt_id, attempt_scored) + async def clone_attempt(self, question_id: str, attempt_id: str, new_attempt_id: str) -> None: + """Clones the attempt.""" + data: dict[str, JsonValue] | None = None + score: ScoreModel | None = None + + state = await self._state_manager.read_attempt_state(question_id, attempt_id) + seed = await self._state_manager.read_attempt_seed(question_id, attempt_id) + + await self._state_manager.write_attempt_state(question_id, new_attempt_id, state) + await self._state_manager.write_attempt_seed(question_id, new_attempt_id, seed) + + try: + data = await self._state_manager.read_attempt_data(question_id, attempt_id) + except webserver_errors.MissingAttemptDataError: + pass + else: + await self._state_manager.write_attempt_data(question_id, new_attempt_id, data) + + try: + score = await self._state_manager.read_attempt_score(question_id, attempt_id) + except webserver_errors.MissingAttemptScoreError: + pass + else: + await self._state_manager.write_attempt_score(question_id, new_attempt_id, score) + async def _get_or_start_attempt( self, question_id: str, diff --git a/questionpy_sdk/webserver/routes/attempt.py b/questionpy_sdk/webserver/routes/attempt.py index a9a67319..20fc54ea 100644 --- a/questionpy_sdk/webserver/routes/attempt.py +++ b/questionpy_sdk/webserver/routes/attempt.py @@ -87,3 +87,18 @@ async def post(self) -> web.Response: ) as err: raise web.HTTPBadRequest(text=str(err)) from err return web.Response() + + +@routes.view( + f"/question/{{question_id:{ID_RE}}}/attempt/{{attempt_id:{ID_RE}}}/clone/{{new_attempt_id:{ID_RE}}}", + name="attempt.clone", +) +class AttemptCloneView(AttemptBaseView): + async def post(self) -> web.Response: + """Clones an attempt.""" + question_id = self.request.match_info["question_id"] + attempt_id = self.request.match_info["attempt_id"] + new_attempt_id = self.request.match_info["new_attempt_id"] + + await self.controller.clone_attempt(question_id, attempt_id, new_attempt_id) + return web.Response() diff --git a/tests/questionpy_sdk/webserver/controllers/attempt/test_controller.py b/tests/questionpy_sdk/webserver/controllers/attempt/test_controller.py index 42701f9a..7a6c491f 100644 --- a/tests/questionpy_sdk/webserver/controllers/attempt/test_controller.py +++ b/tests/questionpy_sdk/webserver/controllers/attempt/test_controller.py @@ -147,3 +147,44 @@ async def test_score_attempt( args, _ = mock_state_manager.write_attempt_score.call_args attempt_scored = args[2] assert attempt_scored.score == 0.9 + + +@pytest.mark.parametrize( + ("data_missing", "score_missing"), + [ + (False, False), + (True, False), + (False, True), + (True, True), + ], +) +async def test_clone_attempt( + data_missing: bool, score_missing: bool, controller: AttemptController, mock_state_manager: AsyncMock +) -> None: + if data_missing: + mock_state_manager.read_attempt_data.side_effect = webserver_errors.MissingAttemptDataError + if score_missing: + mock_state_manager.read_attempt_score.side_effect = webserver_errors.MissingAttemptScoreError + + await controller.clone_attempt("QaKxpanc", "AepM0AFN", "Pei2ohya") + + args, _ = mock_state_manager.write_attempt_state.call_args + assert args == ("QaKxpanc", "Pei2ohya", "attempt_state") + + args, _ = mock_state_manager.write_attempt_seed.call_args + assert args == ("QaKxpanc", "Pei2ohya", 1234) + + if data_missing: + mock_state_manager.write_attempt_data.assert_not_called() + else: + args, _ = mock_state_manager.write_attempt_data.call_args + assert args == ("QaKxpanc", "Pei2ohya", {"answer": "42"}) + + if score_missing: + mock_state_manager.write_attempt_score.assert_not_called() + else: + args, _ = mock_state_manager.write_attempt_score.call_args + assert args[0] == "QaKxpanc" + assert args[1] == "Pei2ohya" + assert args[2].scoring_code == ScoringCode.AUTOMATICALLY_SCORED + assert args[2].score == 1.0 diff --git a/tests/questionpy_sdk/webserver/routes/test_attempt.py b/tests/questionpy_sdk/webserver/routes/test_attempt.py index ce8345de..c7aa706e 100644 --- a/tests/questionpy_sdk/webserver/routes/test_attempt.py +++ b/tests/questionpy_sdk/webserver/routes/test_attempt.py @@ -97,3 +97,10 @@ async def test_post_attempt_score(client: TestClient, mock_controller: AsyncMock async with client.post("/question/myuQ2JWl/attempt/UY9ryXzq/score") as resp: assert resp.status == HTTPOk.status_code mock_controller.score_attempt.assert_awaited_once_with("myuQ2JWl", "UY9ryXzq") + + +@pytest.mark.app_routes(attempt.routes) +async def test_post_attempt_clone(client: TestClient, mock_controller: AsyncMock) -> None: + async with client.post("/question/myuQ2JWl/attempt/UY9ryXzq/clone/Bu2boh5u") as resp: + assert resp.status == HTTPOk.status_code + mock_controller.clone_attempt.assert_awaited_once_with("myuQ2JWl", "UY9ryXzq", "Bu2boh5u") From 55cc241abf8e43757d0ff3e82d0cb294c0a04682 Mon Sep 17 00:00:00 2001 From: Mirko Dietrich Date: Mon, 20 Oct 2025 11:18:26 +0200 Subject: [PATCH 02/11] feat(frontend): add attempt cloning Ref: #238 --- .../src/components/attempt/AttemptCard.vue | 20 ++++++-- .../src/components/attempt/AttemptList.vue | 48 ++++++++++++++++++- .../src/components/attempt/AttemptPreview.vue | 13 +++++ frontend/src/composables/attempt/index.ts | 1 + .../composables/attempt/useCloneAttempt.ts | 33 +++++++++++++ frontend/src/main.ts | 1 + frontend/src/queries/attempt.ts | 42 +++++++++++----- frontend/src/queries/index.ts | 1 + frontend/src/styles/global-styles.scss | 11 +++++ frontend/src/types/typeUtils.ts | 1 - 10 files changed, 153 insertions(+), 18 deletions(-) create mode 100644 frontend/src/composables/attempt/useCloneAttempt.ts create mode 100644 frontend/src/styles/global-styles.scss diff --git a/frontend/src/components/attempt/AttemptCard.vue b/frontend/src/components/attempt/AttemptCard.vue index 8bf3d981..6ea0bf03 100644 --- a/frontend/src/components/attempt/AttemptCard.vue +++ b/frontend/src/components/attempt/AttemptCard.vue @@ -53,8 +53,9 @@ - - Clone + Clone Export () +const emit = defineEmits<{ + cloned: [newAttemptId: string] +}>() + const attemptLocation = { name: 'question-attempt', params: { questionId, attemptId } } as const const deleteAttempt = useDeleteAttempt(questionId, attemptId) +const cloneAttempt = useCloneAttempt(questionId, attemptId) const { isActive: isCurrentPreviewActive } = useLink({ to: attemptLocation }) const { isScored, displayScore, displayStatus } = useAttemptDisplay(attemptData) const cardComponent = computed(() => (collapsible ? CollapsibleCard : BCard)) + +const cloneClick = async () => { + const newAttemptId = await cloneAttempt() + if (newAttemptId) { + emit('cloned', newAttemptId) + } +} diff --git a/frontend/src/components/attempt/AttemptList.vue b/frontend/src/components/attempt/AttemptList.vue index 713a0dd0..21d389f6 100644 --- a/frontend/src/components/attempt/AttemptList.vue +++ b/frontend/src/components/attempt/AttemptList.vue @@ -11,12 +11,13 @@ This question has no attempts yet. diff --git a/frontend/src/components/attempt/AttemptPreview.vue b/frontend/src/components/attempt/AttemptPreview.vue index a5e8d5f2..a817a910 100644 --- a/frontend/src/components/attempt/AttemptPreview.vue +++ b/frontend/src/components/attempt/AttemptPreview.vue @@ -53,6 +53,7 @@ :attempt-data="attemptData" :question-id="questionId" :attempt-id="attemptId" + @cloned="handleAttemptCloned" collapsible variant="info" /> @@ -66,6 +67,7 @@ import IMdiEdit from '~icons/mdi/edit' import IMdiRestart from '~icons/mdi/restart' import IMdiScore from '~icons/mdi/score' import { ref } from 'vue' +import { useRouter } from 'vue-router' import { useAttemptDisplay } from '@/composables/attempt' import useAttempt from '@/composables/attempt/useAttempt' @@ -78,6 +80,8 @@ const { questionId, attemptId } = defineProps<{ attemptId: string }>() +const router = useRouter() + const { asyncStatus, attemptData, @@ -107,4 +111,13 @@ async function saveAndSubmit() { await score() } } + +const handleAttemptCloned = async (attemptId: string) => { + await router.push({ + name: 'question', + params: { questionId }, + // Tell AttemptList to scrollTo/highlight the new attempt + state: { highlightAttemptId: attemptId }, + }) +} diff --git a/frontend/src/composables/attempt/index.ts b/frontend/src/composables/attempt/index.ts index 45797f14..cbdd25cc 100644 --- a/frontend/src/composables/attempt/index.ts +++ b/frontend/src/composables/attempt/index.ts @@ -6,5 +6,6 @@ export { default as useAttempt, type UseAttemptReturn } from './useAttempt' export { default as useAttemptDisplay } from './useAttemptDisplay' +export { default as useCloneAttempt } from './useCloneAttempt' export { default as useCreateAttempt } from './useCreateAttempt' export { default as useDeleteAttempt } from './useDeleteAttempt' diff --git a/frontend/src/composables/attempt/useCloneAttempt.ts b/frontend/src/composables/attempt/useCloneAttempt.ts new file mode 100644 index 00000000..263391ae --- /dev/null +++ b/frontend/src/composables/attempt/useCloneAttempt.ts @@ -0,0 +1,33 @@ +/* + * 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 + */ + +import { generateId } from '@/composables/composableUtils' +import { usePostAttemptCloneMutation } from '@/queries' +import useAppStateStore from '@/stores/useAppStateStore' + +/** + * Composable that returns a function to clone an attempt. + * + * @param questionId The ID of the question the attempt belongs to. + * @param attemptId The ID of the attempt to clone. + * @returns The ID of the new attempt. + */ +function useCloneAttempt(questionId: string, attemptId: string) { + const newAttemptId = generateId() + const { mutateAsync } = usePostAttemptCloneMutation(questionId, attemptId, newAttemptId) + const { setError } = useAppStateStore() + + return async () => { + try { + await mutateAsync() + return newAttemptId + } catch (err) { + setError(err) + } + } +} + +export default useCloneAttempt diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 6075adf2..def72b9f 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -8,6 +8,7 @@ import 'bootstrap/scss/bootstrap.scss' import 'bootstrap-vue-next/dist/bootstrap-vue-next.css' // Poly-fill `Regexp.escape()` (https://caniuse.com/mdn-javascript_builtins_regexp_escape) import 'core-js/actual/regexp/escape' +import './styles/global-styles.scss' import { PiniaColada } from '@pinia/colada' import { createBootstrap } from 'bootstrap-vue-next' diff --git a/frontend/src/queries/attempt.ts b/frontend/src/queries/attempt.ts index 757e602a..1494db04 100644 --- a/frontend/src/queries/attempt.ts +++ b/frontend/src/queries/attempt.ts @@ -13,12 +13,14 @@ import type { AttemptData, AttemptRenderData } from '@/types' import { delete_, get, post } from './fetch' import QUERY_KEYS from './queryKeys' -type InvalidateQueries = ReturnType['invalidateQueries'] +function useInvalidateAttempt(questionId: string, attemptId?: string) { + const { invalidateQueries } = useQueryCache() -function makeAttemptInvalidator(questionId: string, attemptId: string, invalidateQueries: InvalidateQueries) { return () => { invalidateQueries({ key: QUERY_KEYS.attempt.list(questionId) }) - invalidateQueries({ key: QUERY_KEYS.attempt.byId(questionId, attemptId) }) + if (attemptId) { + invalidateQueries({ key: QUERY_KEYS.attempt.byId(questionId, attemptId) }) + } } } @@ -44,14 +46,14 @@ const useAttemptListQuery = (questionId: string) => * @returns An query return object. */ function useAttemptQuery(questionId: string, attemptId: string) { - const { invalidateQueries } = useQueryCache() + const invalidateAttempt = useInvalidateAttempt(questionId) const { displayOptions } = storeToRefs(useDisplayOptionsStore()) return useQuery({ key: () => QUERY_KEYS.attempt.renderedbyId(questionId, attemptId, displayOptions.value), query: () => get(`question/${questionId}/attempt/${attemptId}`, displayOptions.value).then((data) => { - invalidateQueries({ key: QUERY_KEYS.attempt.list(questionId) }) + invalidateAttempt() return data }), }) @@ -65,11 +67,11 @@ function useAttemptQuery(questionId: string, attemptId: string) { * @returns A mutation return object. */ function useDeleteAttemptMutation(questionId: string, attemptId: string) { - const { invalidateQueries } = useQueryCache() + const invalidateAttempt = useInvalidateAttempt(questionId, attemptId) return useMutation({ mutation: () => delete_(`question/${questionId}/attempt/${attemptId}`), - onSettled: makeAttemptInvalidator(questionId, attemptId, invalidateQueries), + onSettled: invalidateAttempt, }) } @@ -81,12 +83,12 @@ function useDeleteAttemptMutation(questionId: string, attemptId: string) { * @returns An mutation return object. */ function usePostAttemptMutation(questionId: string, attemptId: string) { - const { invalidateQueries } = useQueryCache() + const invalidateAttempt = useInvalidateAttempt(questionId, attemptId) return useMutation({ mutation: (formData: Record) => post(`question/${questionId}/attempt/${attemptId}`, JSON.stringify(formData)), - onSettled: makeAttemptInvalidator(questionId, attemptId, invalidateQueries), + onSettled: invalidateAttempt, }) } @@ -98,11 +100,28 @@ function usePostAttemptMutation(questionId: string, attemptId: string) { * @returns An mutation return object. */ function usePostAttemptScoreMutation(questionId: string, attemptId: string) { - const { invalidateQueries } = useQueryCache() + const invalidateAttempt = useInvalidateAttempt(questionId, attemptId) return useMutation({ mutation: () => post(`question/${questionId}/attempt/${attemptId}/score`), - onSettled: makeAttemptInvalidator(questionId, attemptId, invalidateQueries), + onSettled: invalidateAttempt, + }) +} + +/** + * Clone an attempt. + * + * @param questionId The ID of the question. + * @param attemptId The ID of the attempt. + * @param newAttemptId The ID of the new attempt. + * @returns An mutation return object. + */ +function usePostAttemptCloneMutation(questionId: string, attemptId: string, newAttemptId: string) { + const invalidateAttempt = useInvalidateAttempt(questionId, attemptId) + + return useMutation({ + mutation: () => post(`question/${questionId}/attempt/${attemptId}/clone/${newAttemptId}`), + onSettled: invalidateAttempt, }) } @@ -110,6 +129,7 @@ export { useAttemptListQuery, useAttemptQuery, useDeleteAttemptMutation, + usePostAttemptCloneMutation, usePostAttemptMutation, usePostAttemptScoreMutation, } diff --git a/frontend/src/queries/index.ts b/frontend/src/queries/index.ts index c7cc2141..3e662357 100644 --- a/frontend/src/queries/index.ts +++ b/frontend/src/queries/index.ts @@ -8,6 +8,7 @@ export { useAttemptListQuery, useAttemptQuery, useDeleteAttemptMutation, + usePostAttemptCloneMutation, usePostAttemptMutation, usePostAttemptScoreMutation, } from './attempt' diff --git a/frontend/src/styles/global-styles.scss b/frontend/src/styles/global-styles.scss new file mode 100644 index 00000000..fb59abd5 --- /dev/null +++ b/frontend/src/styles/global-styles.scss @@ -0,0 +1,11 @@ +@keyframes highlight-pulse { + 0% { + box-shadow: 0 0 0 0 rgba($primary-border-subtle, 0); + } + 50% { + box-shadow: 0 0 0 $spacer * 0.5 $primary-border-subtle; + } + 100% { + box-shadow: 0 0 0 0 rgba($primary-border-subtle, 0); + } +} diff --git a/frontend/src/types/typeUtils.ts b/frontend/src/types/typeUtils.ts index 262edbb4..c961b0e4 100644 --- a/frontend/src/types/typeUtils.ts +++ b/frontend/src/types/typeUtils.ts @@ -7,7 +7,6 @@ import type { DetailedServerError, EditableElement, FormElement, HasElements } from '.' /** Utility function to be used as exhaustion check. */ - function assertNever(value: never): never { throw new Error(`This code should never be reached. Value='${value}'`) } From 20f5503d5cb2ec3d5b0c3f1bd8f50eb9e9baecd4 Mon Sep 17 00:00:00 2001 From: Mirko Dietrich Date: Mon, 20 Oct 2025 14:56:04 +0200 Subject: [PATCH 03/11] feat(webserver): implement question cloning Ref: #238 --- .../controllers/question/controller.py | 4 ++++ questionpy_sdk/webserver/routes/question.py | 15 +++++++++++++++ .../controllers/question/test_controller.py | 9 +++++++++ .../webserver/routes/test_question.py | 18 +++++++++++++++++- 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/questionpy_sdk/webserver/controllers/question/controller.py b/questionpy_sdk/webserver/controllers/question/controller.py index 726fa023..74289fcb 100644 --- a/questionpy_sdk/webserver/controllers/question/controller.py +++ b/questionpy_sdk/webserver/controllers/question/controller.py @@ -92,6 +92,10 @@ async def delete_question(self, question_id: str) -> None: async def delete_all_questions(self) -> None: await self._state_manager.delete_all_questions() + async def clone_question(self, question_id: str, new_question_id: str) -> None: + state = await self._state_manager.read_question_state(question_id) + await self._state_manager.write_question_state(new_question_id, state) + @staticmethod def _section_names_from_definition(form_definition: OptionsFormDefinition) -> list[str]: return [section.name for section in form_definition.sections] diff --git a/questionpy_sdk/webserver/routes/question.py b/questionpy_sdk/webserver/routes/question.py index c74f848a..f555265b 100644 --- a/questionpy_sdk/webserver/routes/question.py +++ b/questionpy_sdk/webserver/routes/question.py @@ -69,3 +69,18 @@ async def post(self) -> web.Response: return web.json_response(err.errors, status=HTTPUnprocessableEntity.status_code) return web.json_response() + + +@routes.view(f"/question/{{question_id:{ID_RE}}}/clone/{{new_question_id:{ID_RE}}}", name="question.clone") +class QuestionCloneView(QuestionBaseView): + async def post(self) -> web.Response: + """Clones a question.""" + question_id = self.request.match_info["question_id"] + new_question_id = self.request.match_info["new_question_id"] + + try: + await self.controller.clone_question(question_id, new_question_id) + except MissingQuestionStateError as err: + raise HTTPNotFound from err + + return web.Response() diff --git a/tests/questionpy_sdk/webserver/controllers/question/test_controller.py b/tests/questionpy_sdk/webserver/controllers/question/test_controller.py index 94a79647..f955e97f 100644 --- a/tests/questionpy_sdk/webserver/controllers/question/test_controller.py +++ b/tests/questionpy_sdk/webserver/controllers/question/test_controller.py @@ -89,3 +89,12 @@ async def test_save_options_state( mock_state_manager.read_question_state.assert_called_once() mock_state_manager.write_question_state.assert_called_once_with("QaKxpanc", "question_state") + + +async def test_clone_question( + controller: QuestionController, mock_state_manager: AsyncMock, mock_worker: AsyncMock +) -> None: + await controller.clone_question("QaKxpanc", "Bu2boh5u") + + mock_state_manager.read_question_state.assert_called_once() + mock_state_manager.write_question_state.assert_called_once_with("Bu2boh5u", "question_state") diff --git a/tests/questionpy_sdk/webserver/routes/test_question.py b/tests/questionpy_sdk/webserver/routes/test_question.py index b51640d1..d0757c5d 100644 --- a/tests/questionpy_sdk/webserver/routes/test_question.py +++ b/tests/questionpy_sdk/webserver/routes/test_question.py @@ -6,10 +6,11 @@ import pytest from aiohttp.test_utils import TestClient -from aiohttp.web_exceptions import HTTPOk, HTTPUnprocessableEntity +from aiohttp.web_exceptions import HTTPNotFound, HTTPOk, HTTPUnprocessableEntity from questionpy import OptionsFormValidationError from questionpy_common.elements import OptionsFormDefinition +from questionpy_sdk.webserver.errors import MissingQuestionStateError from questionpy_sdk.webserver.routes.question import routes @@ -72,3 +73,18 @@ async def test_post_question_state_validation_error(client: TestClient, mock_con assert resp.status == HTTPUnprocessableEntity.status_code data = await resp.json() assert data["some"] == "error" + + +@pytest.mark.app_routes(routes) +async def test_post_question_clone(client: TestClient, mock_controller: AsyncMock) -> None: + async with client.post("/question/myuQ2JWl/clone/Bu2boh5u") as resp: + assert resp.status == HTTPOk.status_code + mock_controller.clone_question.assert_awaited_once_with("myuQ2JWl", "Bu2boh5u") + + +@pytest.mark.app_routes(routes) +async def test_post_question_clone_not_found(client: TestClient, mock_controller: AsyncMock) -> None: + mock_controller.clone_question.side_effect = MissingQuestionStateError + + async with client.post("/question/myuQ2JWl/clone/Bu2boh5u") as resp: + assert resp.status == HTTPNotFound.status_code From c7b725012be043ba54760e0a188452cb0ea9bbde Mon Sep 17 00:00:00 2001 From: Mirko Dietrich Date: Mon, 20 Oct 2025 16:11:00 +0200 Subject: [PATCH 04/11] feat(frontend): add question cloning Also, refactor scrolling/highlighting behavior into `useHighlightOnInsert` composable. Closes: #238 --- .../src/components/attempt/AttemptList.vue | 51 ++--------- .../src/components/question/QuestionCard.vue | 19 +++- .../src/components/question/QuestionList.vue | 16 +++- frontend/src/composables/common/index.ts | 1 + .../common/useHighlightOnInsert.ts | 88 +++++++++++++++++++ frontend/src/composables/question/index.ts | 1 + .../composables/question/useCloneQuestion.ts | 31 +++++++ .../src/pages/question/[questionId]/index.vue | 20 ++++- frontend/src/queries/index.ts | 1 + frontend/src/queries/question.ts | 21 +++++ frontend/src/styles/_global.scss | 5 ++ frontend/vite.config.ts | 3 +- 12 files changed, 206 insertions(+), 51 deletions(-) create mode 100644 frontend/src/composables/common/useHighlightOnInsert.ts create mode 100644 frontend/src/composables/question/useCloneQuestion.ts create mode 100644 frontend/src/styles/_global.scss diff --git a/frontend/src/components/attempt/AttemptList.vue b/frontend/src/components/attempt/AttemptList.vue index 21d389f6..f2fd3727 100644 --- a/frontend/src/components/attempt/AttemptList.vue +++ b/frontend/src/components/attempt/AttemptList.vue @@ -12,12 +12,12 @@ This question has no attempts yet. @@ -86,7 +53,7 @@ watch(attempts, async (newAttempts, oldAttempts) => { } &.highlight { - animation: highlight-pulse 1500ms ease-in-out; + @include highlight-pulse; } } diff --git a/frontend/src/components/question/QuestionCard.vue b/frontend/src/components/question/QuestionCard.vue index 9f9c66df..1aeddf45 100644 --- a/frontend/src/components/question/QuestionCard.vue +++ b/frontend/src/components/question/QuestionCard.vue @@ -18,8 +18,9 @@
{{ data }}
- - Clone + Clone Export () +const emit = defineEmits<{ + cloned: [newQuestionId: string] +}>() + const questionLocation = { name: 'question', params: { questionId } } as const const { colorMode } = storeToRefs(useAppStateStore()) const deleteQuestion = useDeleteQuestion(questionId) +const cloneQuestion = useCloneQuestion(questionId) const { isActive: isCurrentPreviewActive } = useLink({ to: questionLocation }) const cardVariant = computed(() => (colorMode.value === 'dark' ? 'dark' : 'light')) + +const cloneClick = async () => { + const newQuestionId = await cloneQuestion() + if (newQuestionId) { + emit('cloned', newQuestionId) + } +} diff --git a/frontend/src/components/question/QuestionList.vue b/frontend/src/components/question/QuestionList.vue index d1771e36..226bce2c 100644 --- a/frontend/src/components/question/QuestionList.vue +++ b/frontend/src/components/question/QuestionList.vue @@ -12,12 +12,13 @@ This package has no questions yet. import { computed } from 'vue' +import { useHighlightOnInsert } from '@/composables/common' import { useQuestionStatesQuery } from '@/queries' import { isDetailedServerError } from '@/types' import type { DetailedServerError, OptionsFormData } from '@/types' const { asyncStatus, error, data } = useQuestionStatesQuery() +const generateHtmlId = (questionId: string) => `question-${questionId}` + const questions = computed>(() => { if (data.value === undefined) { return {} @@ -47,6 +51,10 @@ const questions = computed Object.keys(questions.value).length) const hasInvalidStates = computed(() => Object.values(questions.value).some((q) => q.error)) + +const { handleNewItem, highlightedIds } = useHighlightOnInsert(questions, generateHtmlId, { + historyStateKey: 'highlightQuestionId', +}) diff --git a/frontend/src/composables/common/index.ts b/frontend/src/composables/common/index.ts index 2960fd5c..35118529 100644 --- a/frontend/src/composables/common/index.ts +++ b/frontend/src/composables/common/index.ts @@ -5,3 +5,4 @@ */ export { default as useConfirmModal } from './useConfirmModal' +export { default as useHighlightOnInsert } from './useHighlightOnInsert' diff --git a/frontend/src/composables/common/useHighlightOnInsert.ts b/frontend/src/composables/common/useHighlightOnInsert.ts new file mode 100644 index 00000000..5847d034 --- /dev/null +++ b/frontend/src/composables/common/useHighlightOnInsert.ts @@ -0,0 +1,88 @@ +/* + * 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 + */ + +import { nextTick, onMounted, type Ref, ref, watch } from 'vue' + +/** + * Composable for scrolling to and temporarily highlighting items when they are added or referenced in history. + * + * The composable tracks items by ID, scrolls them into view, and exposes which IDs are currently highlighted. + * + * @template T - Type of the items in the collection. + * @param items Reactive object mapping IDs to items. + * @param idGenerator Function to generate the HTML element ID from an item ID. + * @param options Optional configuration: + * - `historyStateKey`: Key in `history.state` to check for an initial item to highlight (default: `'highlightId'`). + * - `scrollBehavior`: Scroll options for `scrollIntoView` (default: `{ behavior: 'smooth', block: 'center' }`). + * - `highlightDelay`: Time to wait before highlighting to compensate for scrolling delay. + * @returns Object with: + * - `handleNewItem(id: string)`: Add an ID to be scrolled to and highlighted when it appears. + * - `highlightedIds`: Reactive set of IDs currently highlighted. + */ +function useHighlightOnInsert( + items: Ref>, + idGenerator: (id: string) => string, + options: { + historyStateKey?: string + scrollBehavior?: ScrollIntoViewOptions + highlightDelay?: 600 + } = {}, +) { + // Default options + const { + historyStateKey = 'highlightId', + scrollBehavior = { + behavior: 'smooth', + block: 'center', + }, + } = options + + const pendingIds = ref>(new Set()) + const highlightedIds = ref>(new Set()) + + // Handle highlighting after navigation + onMounted(() => { + const highlightId = history.state?.[historyStateKey] + if (typeof highlightId === 'string') { + pendingIds.value.add(highlightId) + } + }) + + // Watch for pending items to appear in the list + watch(items, async (newItems, oldItems) => { + for (const id of pendingIds.value) { + if (newItems[id] && !oldItems?.[id]) { + pendingIds.value.delete(id) + await nextTick() + + const el = document.getElementById(idGenerator(id)) + if (el) { + el.scrollIntoView(scrollBehavior) + // Unfortunately, scrollIntoView does not return a Promise + setTimeout(() => { + highlightedIds.value.add(id) + el.addEventListener( + 'animationend', + () => { + highlightedIds.value.delete(id) + }, + { once: true }, + ) + }, options.highlightDelay) + } + } + } + }) + + return { + handleNewItem: (id: string) => { + pendingIds.value.add(id) + }, + highlightedIds, + } +} + +export default useHighlightOnInsert diff --git a/frontend/src/composables/question/index.ts b/frontend/src/composables/question/index.ts index b011c6a4..af005e9f 100644 --- a/frontend/src/composables/question/index.ts +++ b/frontend/src/composables/question/index.ts @@ -4,6 +4,7 @@ * (c) Technische Universität Berlin, innoCampus */ +export { default as useCloneQuestion } from './useCloneQuestion' export { default as useCreateQuestion } from './useCreateQuestion' export { default as useDeleteAllQuestions } from './useDeleteAllQuestions' export { default as useDeleteQuestion } from './useDeleteQuestion' diff --git a/frontend/src/composables/question/useCloneQuestion.ts b/frontend/src/composables/question/useCloneQuestion.ts new file mode 100644 index 00000000..85f83388 --- /dev/null +++ b/frontend/src/composables/question/useCloneQuestion.ts @@ -0,0 +1,31 @@ +/* + * 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 + */ + +import { generateId } from '@/composables/composableUtils' +import { usePostQuestionCloneMutation } from '@/queries' +import useAppStateStore from '@/stores/useAppStateStore' + +/** + * Composable that returns a function to clone a question. + * + * @param questionId The ID of the question to clone. + * @returns The ID of the new question. + */ +function useCloneQuestion(questionId: string) { + const newQuestionId = generateId() + const { mutateAsync } = usePostQuestionCloneMutation(questionId, newQuestionId) + const { setError } = useAppStateStore() + + return async () => { + try { + await mutateAsync() + return newQuestionId + } catch (err) { + setError(err) + } + } +} +export default useCloneQuestion diff --git a/frontend/src/pages/question/[questionId]/index.vue b/frontend/src/pages/question/[questionId]/index.vue index 4b47e9db..9e36de5d 100644 --- a/frontend/src/pages/question/[questionId]/index.vue +++ b/frontend/src/pages/question/[questionId]/index.vue @@ -8,7 +8,13 @@ Back to Package Preview - + Import attempt New attempt @@ -27,7 +33,7 @@ import { useCreateAttempt } from '@/composables/attempt' import { FetchError, useOptionsFormDataQuery } from '@/queries' import type { DetailedServerError } from '@/types' -const route = useRouter() +const router = useRouter() const { params } = useRoute('question') const { data: formData, error } = useOptionsFormDataQuery(params.questionId) const createAttempt = useCreateAttempt(params.questionId) @@ -37,7 +43,7 @@ watch( formData, (value) => { if (value?.is_new) { - route.replace({ name: 'index' }) + router.replace({ name: 'index' }) } }, { immediate: true }, @@ -52,6 +58,14 @@ const detailedServerError = computed(() => ? ({ error: error.value.message, details: error.value.details } satisfies DetailedServerError) : undefined, ) + +const handleAttemptCloned = async (questionId: string) => { + await router.push({ + name: 'index', + // Tell QuestionList to scrollTo/highlight the new question + state: { highlightQuestionId: questionId }, + }) +} diff --git a/frontend/src/queries/index.ts b/frontend/src/queries/index.ts index 3e662357..aa9de8e0 100644 --- a/frontend/src/queries/index.ts +++ b/frontend/src/queries/index.ts @@ -20,5 +20,6 @@ export { useOptionsFormDataQuery, useOptionsFormDefinitionQuery, usePostOptionsFormDataMutation, + usePostQuestionCloneMutation, useQuestionStatesQuery, } from './question' diff --git a/frontend/src/queries/question.ts b/frontend/src/queries/question.ts index b86bcabf..bc5df5d9 100644 --- a/frontend/src/queries/question.ts +++ b/frontend/src/queries/question.ts @@ -123,11 +123,32 @@ function useDeleteOptionsFormDataMutation(questionId: string) { }) } +/** + * Clone a question. + * + * @param questionId The ID of the question. + * @param newQuestionId The ID of the new question. + * @returns An mutation return object. + */ +function usePostQuestionCloneMutation(questionId: string, newQuestionId: string) { + const { invalidateQueries } = useQueryCache() + + const invalidateKey = QUERY_KEYS.question.list() + + return useMutation({ + mutation: () => post(`question/${questionId}/clone/${newQuestionId}`), + onSettled: () => { + invalidateQueries({ key: invalidateKey }) + }, + }) +} + export { useDeleteAllOptionsFormDataMutation, useDeleteOptionsFormDataMutation, useOptionsFormDataQuery, useOptionsFormDefinitionQuery, usePostOptionsFormDataMutation, + usePostQuestionCloneMutation, useQuestionStatesQuery, } diff --git a/frontend/src/styles/_global.scss b/frontend/src/styles/_global.scss new file mode 100644 index 00000000..ef8dc131 --- /dev/null +++ b/frontend/src/styles/_global.scss @@ -0,0 +1,5 @@ +// WARNING: This file must not contain anything that compiles to actual CSS code! + +@mixin highlight-pulse { + animation: highlight-pulse 1500ms ease-in-out; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3fc0d508..eab9d976 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -10,7 +10,7 @@ import VueRouter from 'unplugin-vue-router/vite' import { defineConfig } from 'vite' import vueDevTools from 'vite-plugin-vue-devtools' -// Expose Bootstrap utilities and variables for use in SCSS style blocks. +// Expose Bootstrap utilities/variables, and custom app import for use in component SCSS style blocks. // DO NOT include SCSS that compiles to actual CSS here, as it results in duplicate styles. const additionalScss = ` @import "bootstrap/scss/functions"; @@ -19,6 +19,7 @@ const additionalScss = ` @import "bootstrap/scss/maps"; @import "bootstrap/scss/mixins"; @import "bootstrap/scss/utilities"; +@import "@/styles/_global"; ` // https://vite.dev/config/ From 694458f969060c97881fe367b28a64d23d059cb0 Mon Sep 17 00:00:00 2001 From: Mirko Dietrich Date: Mon, 20 Oct 2025 17:28:16 +0200 Subject: [PATCH 05/11] chore(tests): prevent pytest from recursing into `frontend` folder pytest tries to collect `*.py` files in `frontend/node_modules`, which causes errors. --- e2e/pytest.ini | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/pytest.ini b/e2e/pytest.ini index 77c975cd..98815e30 100644 --- a/e2e/pytest.ini +++ b/e2e/pytest.ini @@ -4,4 +4,4 @@ asyncio_mode = auto # https://playwright.dev/python/docs/test-runners#async-fixtures asyncio_default_test_loop_scope = session asyncio_default_fixture_loop_scope = session -norecursedirs = "tests" +norecursedirs = tests frontend diff --git a/pyproject.toml b/pyproject.toml index 286b6aa8..4f054652 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,7 +91,7 @@ allow-dunder-method-names = ["__get_pydantic_core_schema__"] addopts = "--doctest-modules --ignore examples" markers = ["app_routes", "render_params", "source_pkg", "ui_file"] # We run E2E tests separately -norecursedirs = "e2e" +norecursedirs = "e2e frontend" # https://github.com/pytest-dev/pytest-asyncio#auto-mode asyncio_mode = "auto" # Module scope is recommended: https://pytest-asyncio.readthedocs.io/en/stable/concepts.html#asyncio-event-loops From 03ec80247c7242b9348b1b60b1cbd464b9096556 Mon Sep 17 00:00:00 2001 From: Mirko Dietrich Date: Mon, 27 Oct 2025 15:17:12 +0100 Subject: [PATCH 06/11] fix(frontend): generate a fresh ID for each clone invocation --- frontend/src/composables/attempt/useCloneAttempt.ts | 4 ++-- frontend/src/composables/question/useCloneQuestion.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/composables/attempt/useCloneAttempt.ts b/frontend/src/composables/attempt/useCloneAttempt.ts index 263391ae..b750159b 100644 --- a/frontend/src/composables/attempt/useCloneAttempt.ts +++ b/frontend/src/composables/attempt/useCloneAttempt.ts @@ -16,11 +16,11 @@ import useAppStateStore from '@/stores/useAppStateStore' * @returns The ID of the new attempt. */ function useCloneAttempt(questionId: string, attemptId: string) { - const newAttemptId = generateId() - const { mutateAsync } = usePostAttemptCloneMutation(questionId, attemptId, newAttemptId) const { setError } = useAppStateStore() return async () => { + const newAttemptId = generateId() + const { mutateAsync } = usePostAttemptCloneMutation(questionId, attemptId, newAttemptId) try { await mutateAsync() return newAttemptId diff --git a/frontend/src/composables/question/useCloneQuestion.ts b/frontend/src/composables/question/useCloneQuestion.ts index 85f83388..af98df3e 100644 --- a/frontend/src/composables/question/useCloneQuestion.ts +++ b/frontend/src/composables/question/useCloneQuestion.ts @@ -15,11 +15,11 @@ import useAppStateStore from '@/stores/useAppStateStore' * @returns The ID of the new question. */ function useCloneQuestion(questionId: string) { - const newQuestionId = generateId() - const { mutateAsync } = usePostQuestionCloneMutation(questionId, newQuestionId) const { setError } = useAppStateStore() return async () => { + const newQuestionId = generateId() + const { mutateAsync } = usePostQuestionCloneMutation(questionId, newQuestionId) try { await mutateAsync() return newQuestionId @@ -28,4 +28,5 @@ function useCloneQuestion(questionId: string) { } } } + export default useCloneQuestion From 90bbfa522fe73909de37d85fd24443f3650c6639 Mon Sep 17 00:00:00 2001 From: Mirko Dietrich Date: Mon, 27 Oct 2025 15:23:17 +0100 Subject: [PATCH 07/11] fix(backend): handle `HTTPNotFound` and `HTTPConflict` consistently in clone operations --- .../controllers/attempt/controller.py | 7 +++++ .../controllers/question/controller.py | 21 +++++++++----- questionpy_sdk/webserver/errors.py | 14 +++++++++- questionpy_sdk/webserver/routes/attempt.py | 14 ++++++---- questionpy_sdk/webserver/routes/question.py | 4 ++- .../controllers/attempt/test_controller.py | 9 ++++-- .../controllers/question/test_controller.py | 11 +++++--- .../webserver/routes/test_attempt.py | 28 ++++++++++++++++++- .../webserver/routes/test_question.py | 12 ++++++-- 9 files changed, 95 insertions(+), 25 deletions(-) diff --git a/questionpy_sdk/webserver/controllers/attempt/controller.py b/questionpy_sdk/webserver/controllers/attempt/controller.py index 85d304f0..e43f3996 100644 --- a/questionpy_sdk/webserver/controllers/attempt/controller.py +++ b/questionpy_sdk/webserver/controllers/attempt/controller.py @@ -104,12 +104,19 @@ async def clone_attempt(self, question_id: str, attempt_id: str, new_attempt_id: data: dict[str, JsonValue] | None = None score: ScoreModel | None = None + # Ensure we're not overwriting an existing attempt + attempts = await self._state_manager.read_attempts(question_id) + if new_attempt_id in attempts: + raise webserver_errors.DuplicateAttemptError + + # state and seed must exist... state = await self._state_manager.read_attempt_state(question_id, attempt_id) seed = await self._state_manager.read_attempt_seed(question_id, attempt_id) await self._state_manager.write_attempt_state(question_id, new_attempt_id, state) await self._state_manager.write_attempt_seed(question_id, new_attempt_id, seed) + # ...data and score might not try: data = await self._state_manager.read_attempt_data(question_id, attempt_id) except webserver_errors.MissingAttemptDataError: diff --git a/questionpy_sdk/webserver/controllers/question/controller.py b/questionpy_sdk/webserver/controllers/question/controller.py index 74289fcb..c8f0c7de 100644 --- a/questionpy_sdk/webserver/controllers/question/controller.py +++ b/questionpy_sdk/webserver/controllers/question/controller.py @@ -5,12 +5,12 @@ from pydantic import ConfigDict from pydantic.dataclasses import dataclass +import questionpy_sdk.webserver.errors as webserver_errors 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 DetailedServerError, MissingQuestionStateError, format_error @dataclass(config=ConfigDict(use_attribute_docstrings=True)) @@ -28,7 +28,7 @@ class QuestionController(BaseController): async def get_form_definition(self, question_id: str) -> OptionsFormDefinition: try: state = await self._state_manager.read_question_state(question_id) - except MissingQuestionStateError: + except webserver_errors.MissingQuestionStateError: state = None async with self.get_worker() as worker: @@ -36,9 +36,9 @@ async def get_form_definition(self, question_id: str) -> OptionsFormDefinition: return form_definition - async def get_questions(self) -> dict[str, OptionsFormData | DetailedServerError]: + async def get_questions(self) -> dict[str, OptionsFormData | webserver_errors.DetailedServerError]: states_str = await self._state_manager.read_question_states() - states: dict[str, OptionsFormData | DetailedServerError] = {} + states: dict[str, OptionsFormData | webserver_errors.DetailedServerError] = {} if len(states_str) > 0: async with self.get_worker() as worker: @@ -47,7 +47,9 @@ async def get_questions(self) -> dict[str, OptionsFormData | DetailedServerError 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)) + states[question_id] = webserver_errors.DetailedServerError( + type(err).__name__, webserver_errors.format_error(err) + ) else: section_names = self._section_names_from_definition(form_definition) flat_form_data = flatten_form_data(form_data, section_names) @@ -59,7 +61,7 @@ async def get_options_state(self, question_id: str) -> OptionsStateResponse: try: state = await self._state_manager.read_question_state(question_id) is_new = False - except MissingQuestionStateError: + except webserver_errors.MissingQuestionStateError: state = None is_new = True @@ -76,7 +78,7 @@ async def save_options_state(self, question_id: str, data: OptionsFormData) -> N try: old_state = await self._state_manager.read_question_state(question_id) - except MissingQuestionStateError: + except webserver_errors.MissingQuestionStateError: old_state = None async with self.get_worker() as worker: @@ -93,6 +95,11 @@ async def delete_all_questions(self) -> None: await self._state_manager.delete_all_questions() async def clone_question(self, question_id: str, new_question_id: str) -> None: + # Ensure we're not overwriting an existing question + questions = await self._state_manager.read_question_states() + if new_question_id in questions: + raise webserver_errors.DuplicateQuestionError + state = await self._state_manager.read_question_state(question_id) await self._state_manager.write_question_state(new_question_id, state) diff --git a/questionpy_sdk/webserver/errors.py b/questionpy_sdk/webserver/errors.py index 2bdb44d0..f319096b 100644 --- a/questionpy_sdk/webserver/errors.py +++ b/questionpy_sdk/webserver/errors.py @@ -9,17 +9,25 @@ from pydantic_core import ErrorDetails -class MissingStateError(Exception): +class StateError(Exception): message: str def __init__(self) -> None: super().__init__(self.message) +class MissingStateError(StateError): + pass + + class MissingQuestionStateError(MissingStateError): message = "The question state is missing." +class DuplicateQuestionError(StateError): + message = "The question already exists." + + class MissingAttemptStateError(MissingStateError): message = "The attempt state is missing." @@ -36,6 +44,10 @@ class MissingAttemptDataError(MissingStateError): message = "The attempt data is missing." +class DuplicateAttemptError(StateError): + message = "The attempt already exists." + + @dataclass(config=ConfigDict(use_attribute_docstrings=True)) class DetailedServerError: """Represents a server-side error serialized for client display.""" diff --git a/questionpy_sdk/webserver/routes/attempt.py b/questionpy_sdk/webserver/routes/attempt.py index 20fc54ea..ec92edae 100644 --- a/questionpy_sdk/webserver/routes/attempt.py +++ b/questionpy_sdk/webserver/routes/attempt.py @@ -80,11 +80,7 @@ async def post(self) -> web.Response: try: await self.controller.score_attempt(question_id, attempt_id) - except ( - webserver_errors.MissingQuestionStateError, - webserver_errors.MissingAttemptStateError, - webserver_errors.MissingAttemptDataError, - ) as err: + except webserver_errors.MissingStateError as err: raise web.HTTPBadRequest(text=str(err)) from err return web.Response() @@ -100,5 +96,11 @@ async def post(self) -> web.Response: attempt_id = self.request.match_info["attempt_id"] new_attempt_id = self.request.match_info["new_attempt_id"] - await self.controller.clone_attempt(question_id, attempt_id, new_attempt_id) + try: + await self.controller.clone_attempt(question_id, attempt_id, new_attempt_id) + except webserver_errors.MissingStateError as err: + raise web.HTTPNotFound from err + except webserver_errors.DuplicateAttemptError as err: + raise web.HTTPConflict(text=str(err)) from err + return web.Response() diff --git a/questionpy_sdk/webserver/routes/question.py b/questionpy_sdk/webserver/routes/question.py index f555265b..37d02002 100644 --- a/questionpy_sdk/webserver/routes/question.py +++ b/questionpy_sdk/webserver/routes/question.py @@ -9,7 +9,7 @@ from questionpy import OptionsFormValidationError from questionpy_sdk.webserver.constants import ID_RE from questionpy_sdk.webserver.controllers.question import QuestionController -from questionpy_sdk.webserver.errors import MissingQuestionStateError +from questionpy_sdk.webserver.errors import DuplicateQuestionError, MissingQuestionStateError from questionpy_sdk.webserver.routes.base import BaseView routes = web.RouteTableDef() @@ -82,5 +82,7 @@ async def post(self) -> web.Response: await self.controller.clone_question(question_id, new_question_id) except MissingQuestionStateError as err: raise HTTPNotFound from err + except DuplicateQuestionError as err: + raise web.HTTPConflict(text=str(err)) from err return web.Response() diff --git a/tests/questionpy_sdk/webserver/controllers/attempt/test_controller.py b/tests/questionpy_sdk/webserver/controllers/attempt/test_controller.py index 7a6c491f..d24d8078 100644 --- a/tests/questionpy_sdk/webserver/controllers/attempt/test_controller.py +++ b/tests/questionpy_sdk/webserver/controllers/attempt/test_controller.py @@ -107,9 +107,7 @@ async def test_get_attempt_render_errors( ) -async def test_get_attempts( - controller: AttemptController, mock_state_manager: AsyncMock, mock_jinja2_template: Mock -) -> None: +async def test_get_attempts(controller: AttemptController, mock_jinja2_template: Mock) -> None: mock_jinja2_template.render_async.return_value = "Attempt" attempts = await controller.get_attempts("QaKxpanc") @@ -188,3 +186,8 @@ async def test_clone_attempt( assert args[1] == "Pei2ohya" assert args[2].scoring_code == ScoringCode.AUTOMATICALLY_SCORED assert args[2].score == 1.0 + + +async def test_clone_attempt_duplicate(controller: AttemptController) -> None: + with pytest.raises(webserver_errors.DuplicateAttemptError): + await controller.clone_attempt("QaKxpanc", "AepM0AFN", "eTCRKiod") diff --git a/tests/questionpy_sdk/webserver/controllers/question/test_controller.py b/tests/questionpy_sdk/webserver/controllers/question/test_controller.py index f955e97f..63feca9e 100644 --- a/tests/questionpy_sdk/webserver/controllers/question/test_controller.py +++ b/tests/questionpy_sdk/webserver/controllers/question/test_controller.py @@ -10,7 +10,7 @@ from questionpy_common.elements import OptionsFormDefinition, TextInputElement from questionpy_sdk.webserver.controllers.question import QuestionController from questionpy_sdk.webserver.controllers.question.controller import OptionsStateResponse -from questionpy_sdk.webserver.errors import MissingQuestionStateError +from questionpy_sdk.webserver.errors import DuplicateQuestionError, MissingQuestionStateError from questionpy_server.models import QuestionCreated @@ -32,9 +32,7 @@ async def test_get_form_definition( assert isinstance(form_definition.general[0], TextInputElement) -async def test_get_questions( - controller: QuestionController, mock_state_manager: AsyncMock, mock_worker: AsyncMock -) -> None: +async def test_get_questions(controller: QuestionController, mock_worker: AsyncMock) -> None: mock_worker.get_options_form.return_value = ( OptionsFormDefinition(general=[TextInputElement(label="Foo", name="foo")]), {"foo": "Bar"}, @@ -98,3 +96,8 @@ async def test_clone_question( mock_state_manager.read_question_state.assert_called_once() mock_state_manager.write_question_state.assert_called_once_with("Bu2boh5u", "question_state") + + +async def test_clone_question_duplicate(controller: QuestionController, mock_state_manager: AsyncMock) -> None: + with pytest.raises(DuplicateQuestionError): + await controller.clone_question("QaKxpanc", "tKVJTdsv") diff --git a/tests/questionpy_sdk/webserver/routes/test_attempt.py b/tests/questionpy_sdk/webserver/routes/test_attempt.py index c7aa706e..84bd96e1 100644 --- a/tests/questionpy_sdk/webserver/routes/test_attempt.py +++ b/tests/questionpy_sdk/webserver/routes/test_attempt.py @@ -6,8 +6,9 @@ import pytest from aiohttp.test_utils import TestClient -from aiohttp.web_exceptions import HTTPOk +from aiohttp.web_exceptions import HTTPConflict, HTTPNotFound, HTTPOk +import questionpy_sdk.webserver.errors as webserver_errors from questionpy import DisplayRole from questionpy_sdk.webserver.controllers.attempt.controller import AttemptRenderData from questionpy_sdk.webserver.controllers.attempt.data import AttemptData, AttemptStatus @@ -104,3 +105,28 @@ async def test_post_attempt_clone(client: TestClient, mock_controller: AsyncMock async with client.post("/question/myuQ2JWl/attempt/UY9ryXzq/clone/Bu2boh5u") as resp: assert resp.status == HTTPOk.status_code mock_controller.clone_attempt.assert_awaited_once_with("myuQ2JWl", "UY9ryXzq", "Bu2boh5u") + + +@pytest.mark.parametrize( + "exception", + [ + webserver_errors.MissingAttemptStateError, + webserver_errors.MissingAttemptSeedError, + ], +) +@pytest.mark.app_routes(attempt.routes) +async def test_post_attempt_clone_not_found( + exception: webserver_errors.StateError, client: TestClient, mock_controller: AsyncMock +) -> None: + mock_controller.clone_attempt.side_effect = exception + + async with client.post("/question/myuQ2JWl/attempt/UY9ryXzq/clone/Bu2boh5u") as resp: + assert resp.status == HTTPNotFound.status_code + + +@pytest.mark.app_routes(attempt.routes) +async def test_post_attempt_clone_duplicate(client: TestClient, mock_controller: AsyncMock) -> None: + mock_controller.clone_attempt.side_effect = webserver_errors.DuplicateAttemptError + + async with client.post("/question/myuQ2JWl/attempt/UY9ryXzq/clone/Bu2boh5u") as resp: + assert resp.status == HTTPConflict.status_code diff --git a/tests/questionpy_sdk/webserver/routes/test_question.py b/tests/questionpy_sdk/webserver/routes/test_question.py index d0757c5d..884cb8fc 100644 --- a/tests/questionpy_sdk/webserver/routes/test_question.py +++ b/tests/questionpy_sdk/webserver/routes/test_question.py @@ -6,11 +6,11 @@ import pytest from aiohttp.test_utils import TestClient -from aiohttp.web_exceptions import HTTPNotFound, HTTPOk, HTTPUnprocessableEntity +from aiohttp.web_exceptions import HTTPConflict, HTTPNotFound, HTTPOk, HTTPUnprocessableEntity from questionpy import OptionsFormValidationError from questionpy_common.elements import OptionsFormDefinition -from questionpy_sdk.webserver.errors import MissingQuestionStateError +from questionpy_sdk.webserver.errors import DuplicateQuestionError, MissingQuestionStateError from questionpy_sdk.webserver.routes.question import routes @@ -88,3 +88,11 @@ async def test_post_question_clone_not_found(client: TestClient, mock_controller async with client.post("/question/myuQ2JWl/clone/Bu2boh5u") as resp: assert resp.status == HTTPNotFound.status_code + + +@pytest.mark.app_routes(routes) +async def test_post_question_clone_duplicate(client: TestClient, mock_controller: AsyncMock) -> None: + mock_controller.clone_question.side_effect = DuplicateQuestionError + + async with client.post("/question/myuQ2JWl/clone/Bu2boh5u") as resp: + assert resp.status == HTTPConflict.status_code From e3fa6422890aabc074e09544d1d2dc1be7367377 Mon Sep 17 00:00:00 2001 From: Mirko Dietrich Date: Mon, 27 Oct 2025 15:58:47 +0100 Subject: [PATCH 08/11] fix(frontend): avoid unnecessary attempt invalidation when cloning --- frontend/src/queries/attempt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/queries/attempt.ts b/frontend/src/queries/attempt.ts index 1494db04..32f32c86 100644 --- a/frontend/src/queries/attempt.ts +++ b/frontend/src/queries/attempt.ts @@ -117,7 +117,7 @@ function usePostAttemptScoreMutation(questionId: string, attemptId: string) { * @returns An mutation return object. */ function usePostAttemptCloneMutation(questionId: string, attemptId: string, newAttemptId: string) { - const invalidateAttempt = useInvalidateAttempt(questionId, attemptId) + const invalidateAttempt = useInvalidateAttempt(questionId) return useMutation({ mutation: () => post(`question/${questionId}/attempt/${attemptId}/clone/${newAttemptId}`), From fccc889c871329355b52bb2a7f6c41668f4f019d Mon Sep 17 00:00:00 2001 From: Mirko Dietrich Date: Tue, 28 Oct 2025 11:07:42 +0100 Subject: [PATCH 09/11] feat(frontend): add `pendingOperations` store Split `useHighlightOnInsert` into focused composables with well-defined responsibilities. --- .../src/components/attempt/AttemptCard.vue | 15 +--- .../src/components/attempt/AttemptList.vue | 32 +++---- .../src/components/attempt/AttemptPreview.vue | 13 --- frontend/src/components/common/FormGroup.vue | 10 ++- frontend/src/components/common/IconButton.vue | 11 ++- .../src/components/question/QuestionCard.vue | 14 +-- .../src/components/question/QuestionList.vue | 30 +++---- .../src/composables/attempt/useAttempt.ts | 11 +++ .../composables/attempt/useCloneAttempt.ts | 18 +--- .../composables/attempt/useDeleteAttempt.ts | 15 ++++ frontend/src/composables/common/index.ts | 4 +- frontend/src/composables/common/useClone.ts | 71 +++++++++++++++ .../src/composables/common/useDeferredItem.ts | 63 +++++++++++++ .../common/useHighlightOnInsert.ts | 88 ------------------ .../src/composables/common/useHintItem.ts | 90 +++++++++++++++++++ .../question/elements/useIsDisabled.ts | 5 +- .../composables/question/useCloneQuestion.ts | 18 +--- .../question/useDeleteAllQuestions.ts | 5 ++ .../composables/question/useDeleteQuestion.ts | 6 ++ .../composables/question/useFormDataState.ts | 9 ++ .../src/pages/question/[questionId]/index.vue | 16 +--- frontend/src/router.ts | 14 ++- .../src/stores/usePendingOperationsStore.ts | 89 ++++++++++++++++++ 23 files changed, 439 insertions(+), 208 deletions(-) create mode 100644 frontend/src/composables/common/useClone.ts create mode 100644 frontend/src/composables/common/useDeferredItem.ts delete mode 100644 frontend/src/composables/common/useHighlightOnInsert.ts create mode 100644 frontend/src/composables/common/useHintItem.ts create mode 100644 frontend/src/stores/usePendingOperationsStore.ts diff --git a/frontend/src/components/attempt/AttemptCard.vue b/frontend/src/components/attempt/AttemptCard.vue index 6ea0bf03..308ad6c3 100644 --- a/frontend/src/components/attempt/AttemptCard.vue +++ b/frontend/src/components/attempt/AttemptCard.vue @@ -53,7 +53,7 @@ - Clone @@ -98,23 +98,10 @@ const { questionId: string }>() -const emit = defineEmits<{ - cloned: [newAttemptId: string] -}>() - const attemptLocation = { name: 'question-attempt', params: { questionId, attemptId } } as const - const deleteAttempt = useDeleteAttempt(questionId, attemptId) const cloneAttempt = useCloneAttempt(questionId, attemptId) const { isActive: isCurrentPreviewActive } = useLink({ to: attemptLocation }) const { isScored, displayScore, displayStatus } = useAttemptDisplay(attemptData) - const cardComponent = computed(() => (collapsible ? CollapsibleCard : BCard)) - -const cloneClick = async () => { - const newAttemptId = await cloneAttempt() - if (newAttemptId) { - emit('cloned', newAttemptId) - } -} diff --git a/frontend/src/components/attempt/AttemptList.vue b/frontend/src/components/attempt/AttemptList.vue index f2fd3727..8e2931ba 100644 --- a/frontend/src/components/attempt/AttemptList.vue +++ b/frontend/src/components/attempt/AttemptList.vue @@ -9,16 +9,19 @@ - + :ref="registerElementRef(attemptId)" + > + + This question has no attempts yet. @@ -28,24 +31,23 @@ diff --git a/frontend/src/components/common/LoadingIndicator.vue b/frontend/src/components/common/LoadingIndicator.vue index da1f7b1b..68405ee3 100644 --- a/frontend/src/components/common/LoadingIndicator.vue +++ b/frontend/src/components/common/LoadingIndicator.vue @@ -5,5 +5,24 @@ --> + + diff --git a/frontend/src/components/question/QuestionList.vue b/frontend/src/components/question/QuestionList.vue index 31025a17..26c04d77 100644 --- a/frontend/src/components/question/QuestionList.vue +++ b/frontend/src/components/question/QuestionList.vue @@ -5,28 +5,36 @@ --> - - diff --git a/frontend/src/components/common/ListView.vue b/frontend/src/components/common/ListView.vue new file mode 100644 index 00000000..933ce2fc --- /dev/null +++ b/frontend/src/components/common/ListView.vue @@ -0,0 +1,57 @@ + + + + + + + diff --git a/frontend/src/components/question/QuestionList.vue b/frontend/src/components/question/QuestionList.vue index 26c04d77..c9ea2f1e 100644 --- a/frontend/src/components/question/QuestionList.vue +++ b/frontend/src/components/question/QuestionList.vue @@ -7,70 +7,47 @@