diff --git a/backend/app/main.py b/backend/app/main.py index a5ce2d216da951389d2e33694c51ccfae1a96df1..a474e0ee09c95f98a2674ff37f61536e11cb573f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -655,7 +655,7 @@ def feedback_message( background_tasks.add_task( send_websoket_feedback, session_id, - schemas.MessageFeedback.model_validate(feedback).to_dict() + schemas.MessageFeedback.model_validate(feedback).to_dict(), ) return feedback.id diff --git a/backend/app/models.py b/backend/app/models.py index 0dd3cd97ac1e9110442e08ca6dd1eb6425e2055a..e5a1a147b1e1007e5214fc07b17436b8b0d7e5b9 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -219,3 +219,4 @@ class SurveyResponse(Base): question_id = Column(Integer, ForeignKey("survey_questions.id")) selected_id = Column(Integer) response_time = Column(Float) + text = Column(String) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 578edee0c4abb25b4013cb39fea927ef29e362a7..7c4a8b60eb8ee09aaa93e1ea7622ec4f2142d31c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -249,6 +249,7 @@ class SurveyResponseCreate(BaseModel): question_id: int selected_id: int response_time: float + text: str | None = None class SurveyResponse(BaseModel): @@ -261,3 +262,4 @@ class SurveyResponse(BaseModel): question_id: int selected_id: int response_time: float + text: str | None = None diff --git a/frontend/src/lib/api/survey.ts b/frontend/src/lib/api/survey.ts index 8ac70e3b4eae6f900f2af303a3f39232f3a75537..93a27dc2c06701a4ca8e205e47b3621c692dafb3 100644 --- a/frontend/src/lib/api/survey.ts +++ b/frontend/src/lib/api/survey.ts @@ -19,7 +19,8 @@ export async function sendSurveyResponseAPI( group_id: number, question_id: number, option_id: number, - response_time: number + response_time: number, + text: string = '' ) { const response = await axiosInstance.post(`/surveys/responses`, { uuid, @@ -28,7 +29,8 @@ export async function sendSurveyResponseAPI( question_id, group_id, selected_id: option_id, - response_time + response_time, + text }); if (response.status !== 201) { diff --git a/frontend/src/lib/components/surveys/gapfill.svelte b/frontend/src/lib/components/surveys/gapfill.svelte new file mode 100644 index 0000000000000000000000000000000000000000..be1cffaafd8b905448e1ae8ca68ca2cd03306e4d --- /dev/null +++ b/frontend/src/lib/components/surveys/gapfill.svelte @@ -0,0 +1,18 @@ +<script lang="ts"> + export let length; + export let onInput: (value: string) => void; + let content: string = ''; +</script> + +<span class="relative text-blue-500 font-mono tracking-widest px-1" + ><!-- + --><input + class="absolute bg-transparent text-transparent w-full caret-blue-500 focus:outline-none focus:ring-0" + bind:value={content} + on:input={(event) => onInput(event.target.value)} + maxlength={length} + /><!-- + -->{#each Array.from({ length }) as _, i} + <label>{content[i] || '_'}</label> + {/each} +</span> diff --git a/frontend/src/routes/tests/[id]/+page.svelte b/frontend/src/routes/tests/[id]/+page.svelte index 151014c4a446224b6603efc75307c066160490bb..e21b403f5b886f885cd2dacd633fd6e1b89ce214 100644 --- a/frontend/src/routes/tests/[id]/+page.svelte +++ b/frontend/src/routes/tests/[id]/+page.svelte @@ -6,6 +6,8 @@ import { toastWarning } from '$lib/utils/toasts.js'; import { get } from 'svelte/store'; import User from '$lib/types/user.js'; + import type SurveyGroup from '$lib/types/surveyGroup'; + import Gapfill from '$lib/components/surveys/gapfill.svelte'; export let data; @@ -16,14 +18,36 @@ Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); let startTime = new Date().getTime(); - $: step = user ? 1 : 0; + function getSortedQuestions(group: SurveyGroup) { + return group.questions.sort(() => Math.random() - 0.5); + } + + $: step = user ? 2 : 0; $: uuid = user?.email || ''; - $: currentGroupId = 0; - $: currentGroup = survey.groups[currentGroupId]; - $: questionsRandomized = currentGroup.questions.sort(() => Math.random() - 0.5); - $: currentQuestionId = 0; - $: currentQuestion = questionsRandomized[currentQuestionId]; + let currentGroupId = 0; + let currentGroup = survey.groups[currentGroupId]; + let questionsRandomized = getSortedQuestions(currentGroup); + let currentQuestionId = 0; + let currentQuestion = questionsRandomized[currentQuestionId]; + let type = currentQuestion.question.split(':')[0]; + let value = currentQuestion.question.split(':').slice(1).join(':'); + let gaps = type === 'gap' ? gapParts(currentQuestion.question) : null; + + function setGroupId(id: number) { + currentGroupId = id; + currentGroup = survey.groups[currentGroupId]; + questionsRandomized = getSortedQuestions(currentGroup); + setQuestionId(0); + } + + function setQuestionId(id: number) { + currentQuestionId = id; + currentQuestion = questionsRandomized[currentQuestionId]; + type = currentQuestion.question.split(':')[0]; + value = currentQuestion.question.split(':').slice(1).join(':'); + gaps = type === 'gap' ? gapParts(currentQuestion.question) : null; + } async function selectOption(option: string) { if ( @@ -40,7 +64,37 @@ return; } if (currentQuestionId < questionsRandomized.length - 1) { - currentQuestionId++; + setQuestionId(currentQuestionId + 1); + startTime = new Date().getTime(); + } else { + nextGroup(); + } + } + + async function sendGap() { + if (!gaps) return; + + const gapTexts = gaps + .filter((part) => part.gap !== null) + .map((part) => part.gap) + .join('|'); + + if ( + !(await sendSurveyResponseAPI( + uuid, + sid, + survey.id, + currentGroupId, + currentQuestionId, + -1, + (new Date().getTime() - startTime) / 1000, + gapTexts + )) + ) { + return; + } + if (currentQuestionId < questionsRandomized.length - 1) { + setQuestionId(currentQuestionId + 1); startTime = new Date().getTime(); } else { nextGroup(); @@ -49,8 +103,7 @@ function nextGroup() { if (currentGroupId < survey.groups.length - 1) { - currentGroupId++; - currentQuestionId = 0; + setGroupId(currentGroupId + 1); } else { step++; } @@ -68,6 +121,22 @@ step = 1; } + + function gapParts(question: string): { text: string; gap: string | null }[] { + if (!question.startsWith('gap:')) return []; + + const gapText = question.split(':').slice(1).join(':'); + + const parts: { text: string; gap: string | null }[] = []; + + for (let part of gapText.split(/(<.+?>)/g)) { + const isGap = part.startsWith('<') && part.endsWith('>'); + const text = isGap ? part.slice(1, -1) : part; + parts.push({ text: text, gap: isGap ? '' : null }); + } + + return parts; + } </script> {#if step == 0} @@ -76,7 +145,7 @@ <div class="flex mt-8"> <div class="grow border-r-gray-300 border-r py-16"> <p class="mb-4">{$t('surveys.loginUser')}</p> - <a href="/login?redirect=/surveys/{survey.id}" class="button">{$t('button.login')}</a> + <a href="/login?redirect=/tests/{survey.id}" class="button">{$t('button.login')}</a> </div> <div class="grow py-16"> <p class="mb-4">{$t('surveys.loginEmail')}</p> @@ -97,58 +166,70 @@ <button class="button" on:click={() => step++}>{$t('button.next')}</button> </div> {:else if step == 2} - {@const type = currentQuestion.question.split(':')[0]} - {@const value = currentQuestion.question.split(':').slice(1).join(':')} - <div class="mx-auto mt-16 text-center"> - {#if type == 'text'} - <pre>{value}</pre> - {:else if type == 'image'} - <img src={value} alt="Question" /> - {:else if type == 'audio'} - <audio controls autoplay class="rounded-lg mx-auto"> - <source src={value} type="audio/mpeg" /> - Your browser does not support the audio element. - </audio> - {/if} - </div> - - <div class="mx-auto mt-16"> - <div class="flex justify-around min-w-[600px] space-x-10"> - {#each currentQuestion?.options as option (option)} - {@const type = option.split(':')[0]} - {@const value = option.split(':').slice(1).join(':')} - <div - class="h-48 w-48 overflow-hidden rounded-lg border border-black" - on:click={() => selectOption(option)} - role="button" - on:keydown={() => selectOption(option)} - tabindex="0" - > - {#if type === 'text'} - <span - class="flex items-center justify-center h-full w-full text-2xl transition-transform duration-200 ease-in-out transform hover:scale-105" - > - {value} - </span> - {:else if type === 'image'} - <img - src={value} - alt="Option {option}" - class="object-cover h-full w-full transition-transform duration-200 ease-in-out transform hover:scale-105" - /> - {:else if type == 'audio'} - <audio controls class="w-full" on:click|preventDefault|stopPropagation> - <source src={value} type="audio/mpeg" /> - Your browser does not support the audio element. - </audio> + {#if type == 'gap'} + <div class="mx-auto mt-16 center flex flex-col"> + <div> + {#each gaps as part} + {#if part.gap !== null} + <Gapfill length={part.text.length} onInput={(text) => (part.gap = text)} /> + {:else} + {part.text} {/if} - </div> - {/each} + {/each} + </div> + <button class="button mt-8" on:click={sendGap}>{$t('button.next')}</button> </div> - </div> + {:else} + <div class="mx-auto mt-16 text-center"> + {#if type == 'text'} + <pre>{value}</pre> + {:else if type == 'image'} + <img src={value} alt="Question" /> + {:else if type == 'audio'} + <audio controls autoplay class="rounded-lg mx-auto"> + <source src={value} type="audio/mpeg" /> + Your browser does not support the audio element. + </audio> + {/if} + </div> + + <div class="mx-auto mt-16"> + <div class="flex justify-around min-w-[600px] space-x-10"> + {#each currentQuestion?.options as option (option)} + {@const type = option.split(':')[0]} + {@const value = option.split(':').slice(1).join(':')} + <div + class="h-48 w-48 overflow-hidden rounded-lg border border-black" + on:click={() => selectOption(option)} + role="button" + on:keydown={() => selectOption(option)} + tabindex="0" + > + {#if type === 'text'} + <span + class="flex items-center justify-center h-full w-full text-2xl transition-transform duration-200 ease-in-out transform hover:scale-105" + > + {value} + </span> + {:else if type === 'image'} + <img + src={value} + alt="Option {option}" + class="object-cover h-full w-full transition-transform duration-200 ease-in-out transform hover:scale-105" + /> + {:else if type == 'audio'} + <audio controls class="w-full" on:click|preventDefault|stopPropagation> + <source src={value} type="audio/mpeg" /> + Your browser does not support the audio element. + </audio> + {/if} + </div> + {/each} + </div> + </div> + {/if} {:else if step == 3} <div class="mx-auto mt-16 text-center"> - flex maximize width <h1>{$t('surveys.complete')}</h1> </div> {/if} diff --git a/scripts/surveys/groups.csv b/scripts/surveys/groups.csv index 8d6c9a8b8ad918724b2f8f2caf280cbe47c980da..77dd4cc36443afa0424919dd3ae7405dcbf75197 100644 --- a/scripts/surveys/groups.csv +++ b/scripts/surveys/groups.csv @@ -1,3 +1,4 @@ id,title,options 1,Auditory Picture Vocabulary Test - English,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179 2,Test2,2 +3,Gap,3 diff --git a/scripts/surveys/surveys.csv b/scripts/surveys/surveys.csv index 7d69ae18ac86a8be09f7c350d35bc516501acceb..6710c9c2ffd48de780ea30ceb6153d87061a166e 100644 --- a/scripts/surveys/surveys.csv +++ b/scripts/surveys/surveys.csv @@ -1,3 +1,4 @@ id,title,groups 1,Title,1,2 2,Title,2 +3,TitleGap,3