diff --git a/backend/app/crud/tests.py b/backend/app/crud/tests.py index 112bdc7f88e87fbe0a2c1591b3d5bbfc016a723f..c84cde2d594ec58ea9bd52acae09da18102abe8c 100644 --- a/backend/app/crud/tests.py +++ b/backend/app/crud/tests.py @@ -6,7 +6,15 @@ import schemas def create_test(db: Session, test: schemas.TestCreate) -> models.Test: - db_test = models.Test(**test.model_dump()) + db_test = models.Test(**test.model_dump(exclude_unset=True, exclude={"test_task"})) + + if test.test_task: + db_test.test_task = models.TestTask( + groups=db.query(models.TestTaskGroup) + .filter(models.TestTaskGroup.id.in_(test.test_task.groups)) + .all(), + ) + db.add(db_test) db.commit() db.refresh(db_test) @@ -28,9 +36,19 @@ def update_test(db: Session, test: schemas.TestCreate, test_id: int) -> None: ).delete() if test.test_task: - db.query(models.TestTask).filter(models.TestTask.test_id == test_id).update( - {**test.test_task.model_dump(exclude_unset=True)} + + test_task = ( + db.query(models.TestTask).filter(models.TestTask.test_id == test_id).first() ) + + if test_task: + groups = ( + db.query(models.TestTaskGroup) + .filter(models.TestTaskGroup.id.in_(test.test_task.groups)) + .all() + ) + + test_task.groups = groups else: db.query(models.TestTask).filter(models.TestTask.test_id == test_id).delete() @@ -174,3 +192,11 @@ def get_score(db: Session, rid: str): return 0 return corrects / total + + +def get_groups(db: Session): + return db.query(models.TestTaskGroup).all() + + +def get_questions(db: Session): + return db.query(models.TestTaskQuestion).all() diff --git a/backend/app/routes/tests.py b/backend/app/routes/tests.py index 6f63bb74646730d476faeae284e01b449407f37b..a8c7c13750dff17402d59517c9a171b76c9ac706 100644 --- a/backend/app/routes/tests.py +++ b/backend/app/routes/tests.py @@ -1,6 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from starlette.status import HTTP_200_OK import crud import schemas @@ -42,6 +41,22 @@ def get_tests( return crud.get_tests(db, skip) +@require_admin("You do not have permission to get all the groups.") +@testRouter.get("/groups", response_model=list[schemas.TestTaskGroup]) +def get_groups( + db: Session = Depends(get_db), +): + return crud.get_groups(db) + + +@require_admin("You do not have permission to get all the questions.") +@testRouter.get("/questions", response_model=list[schemas.TestTaskQuestion]) +def get_questions( + db: Session = Depends(get_db), +): + return crud.get_questions(db) + + @testRouter.get("/{test_id}", response_model=schemas.Test) def get_test( test_id: int, diff --git a/backend/app/schemas/tests.py b/backend/app/schemas/tests.py index fb49e00e88e488519c47bffbdc2de6908aa8fdb5..41ea6af57544bbb2fcb47bcc086752270c95dfdb 100644 --- a/backend/app/schemas/tests.py +++ b/backend/app/schemas/tests.py @@ -80,11 +80,11 @@ class TestTaskGroup(TestTaskGroupCreate): class TestTaskCreate(BaseModel): - groups: list[TestTaskGroup] = [] + groups: list[int] = [] -class TestTask(TestTaskCreate): - pass +class TestTask(BaseModel): + groups: list[TestTaskGroup] = [] class TestCreate(BaseModel): diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json index 7a5dd49695767d0e1161a7e808b6d5c79ed8e452..ce83af49f7e07ea0579102c0f64bd514cdef6b6f 100644 --- a/frontend/src/lang/fr.json +++ b/frontend/src/lang/fr.json @@ -13,7 +13,9 @@ "users": "Utilisateurs", "sessions": "Sessions", "studies": "Études", - "tests": "Tests" + "tests": "Tests", + "groups": "Groupes", + "questions": "Questions" } }, "chatbox": { @@ -414,6 +416,20 @@ "text": "Contenu", "repeat": "Nombre de répétitions", "duration": "Durée" + }, + "groups": { + "manage": "Gérer les groupes", + "create": "Créer un groupe", + "backtotests": "Revenir aux tests", + "title": { + "edit": "Modifier le groupe", + "create": "Créer un groupe" + } + }, + "questions": { + "manage": "Gérer les questions", + "create": "Créer une question", + "backtogroups": "Revenir aux groupes" } }, "button": { @@ -518,7 +534,16 @@ "tests": "tests", "OrganisationUni": "Organisation/Université", "Address": "Adresse", - "type": "Type" + "type": "Type", + "demo": "Demo", + "randomize": "Aléatoire", + "qcm": "QCM", + "image": "Image", + "text": "Texte", + "audio": "Audio", + "dropdown": "Menu déroulant", + "radio": "Boutons radio", + "gapfill": "Texte à trous" } }, "inputs": { diff --git a/frontend/src/lib/api/tests.ts b/frontend/src/lib/api/tests.ts index 06e1a9fedcd452fe988f1dcbf6e3e0bd72a609c5..1f4b2e8d1a9b75c21a11f3446f650f756c077e97 100644 --- a/frontend/src/lib/api/tests.ts +++ b/frontend/src/lib/api/tests.ts @@ -180,3 +180,63 @@ export async function deleteTestAPI(fetch: fetchType, id: number): Promise<boole if (!response.ok) return false; return true; } + +export async function getTestAPI(fetch: fetchType, id: number): Promise<any> { + const response = await fetch(`/api/tests/${id}`); + if (!response.ok) return null; + const test = await response.json(); + return test; +} + +export async function getTestGroupsAPI(fetch: fetchType): Promise<any> { + const response = await fetch(`/api/tests/groups`); + if (!response.ok) return null; + const groups = await response.json(); + return groups; +} + +export async function getTestQuestionsAPI(fetch: fetchType): Promise<any> { + const response = await fetch(`/api/tests/questions`); + if (!response.ok) return null; + const questions = await response.json(); + return questions; +} + +export async function createTestTaskAPI( + fetch: fetchType, + title: string, + groups: number[] +): Promise<number | null> { + const response = await fetch(`/api/tests`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + test_task: { + groups + } + }) + }); + if (!response.ok) return null; + const test = await response.json(); + return test.id; +} + +export async function updateTestTaskAPI( + fetch: fetchType, + id: number, + title: string, + groups: number[] +): Promise<boolean> { + const response = await fetch(`/api/tests/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + test_task: { + groups + } + }) + }); + return response.ok; +} diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte index 9cd142beacab75f6f6a645bf5936573211a0f82c..b1b4e255f382bdf8edd05d6f14b1e3877b4a1a5a 100644 --- a/frontend/src/lib/components/studies/StudyForm.svelte +++ b/frontend/src/lib/components/studies/StudyForm.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import DateInput from '$lib/components/utils/dateInput.svelte'; - import Draggable from './Draggable.svelte'; + import Draggable from '$lib/components/utils/Draggable.svelte'; import autosize from 'svelte-autosize'; import { toastWarning, toastAlert } from '$lib/utils/toasts'; import { getUserByEmailAPI } from '$lib/api/users'; diff --git a/frontend/src/lib/components/tests/GroupForm.svelte b/frontend/src/lib/components/tests/GroupForm.svelte new file mode 100644 index 0000000000000000000000000000000000000000..67d6856ca90b52b9c2815608eedba21196a8ac7a --- /dev/null +++ b/frontend/src/lib/components/tests/GroupForm.svelte @@ -0,0 +1,125 @@ +<script lang="ts"> + import { t } from '$lib/services/i18n'; + import Draggable from '$lib/components/utils/Draggable.svelte'; + import type TestTaskGroup from '$lib/types/testTaskGroups'; + import { + TestTaskQuestionGapfill, + TestTaskQuestionQcm, + TestTaskQuestionQcmType, + type TestTaskQuestion + } from '$lib/types/testTaskQuestions'; + + const { + group, + possibleQuestions, + message + }: { group: TestTaskGroup | null; possibleQuestions: TestTaskQuestion[]; message: string } = + $props(); + + let questions = $state(group ? [...group.questions] : []); + let selectedQuestion = $state<TestTaskQuestion | null>(null); + + async function deleteGroup() { + if (!group) return; + await group?.delete(); + window.location.href = '/admin/tests/group'; + } +</script> + +<div class="mx-auto w-full max-w-5xl px-4"> + <h2 class="text-xl font-bold m-5 text-center"> + {$t(group ? 'tests.groups.title.edit' : 'tests.groups.title.create')} + </h2> + + {#if message} + <div class="alert alert-error shadow-lg mb-4"> + {message} + </div> + {/if} + + <form method="POST"> + <label for="title" class="label text-sm">{$t('utils.words.title')} *</label> + <input + type="text" + class="input w-full" + name="title" + id="title" + placeholder="Title" + value={group?.title} + required + /> + + <label for="demo" class="label text-sm">{$t('utils.words.demo')}</label> + <label class="flex gap-2"> + {$t('utils.bool.false')} + <input type="checkbox" name="demo" id="demo" class="toggle" /> + {$t('utils.bool.true')} + </label> + + <label for="randomize" class="label text-sm">{$t('utils.words.randomize')}</label> + <label class="flex gap-2"> + {$t('utils.bool.false')} + <input type="checkbox" checked name="randomize" id="randomize" class="toggle" /> + {$t('utils.bool.true')} + </label> + + <span class="label text-sm capitalize">{$t('utils.words.questions')}</span> + <Draggable name="questions[]" bind:items={questions} /> + <div class="flex max-w-full"> + <div class="flex-1 min-w-0"> + <select class="select select-bordered w-full" bind:value={selectedQuestion}> + {#each Object.keys(TestTaskQuestionQcmType) as qcmType} + <optgroup label={`${$t('utils.words.qcm')} - ${$t(`utils.words.${qcmType}`)}`}> + {#each possibleQuestions as question} + {#if question instanceof TestTaskQuestionQcm && question.subType === qcmType} + <option value={question} title={question.value}> + {question.value.substring(0, 120)} + </option> + {/if} + {/each} + </optgroup> + {/each} + <optgroup label={$t('tests.questions.gapfill')}> + {#each possibleQuestions as question} + {#if question instanceof TestTaskQuestionGapfill} + <option value={question} title={question.value}> + {question.value.substring(0, 120)} + </option> + {/if} + {/each} + </optgroup> + </select> + </div> + <button + class="ml-2 button flex-shrink-0" + onclick={(e) => { + e.preventDefault(); + if (selectedQuestion === undefined || selectedQuestion === null) return; + questions = [...questions, selectedQuestion]; + }} + > + + + </button> + </div> + + <input type="hidden" name="id" value={group?.id} /> + + <div class="mt-4 mb-6"> + <button type="submit" class="button"> + {group ? $t('button.update') : $t('button.create')} + </button> + <a class="btn btn-outline float-end ml-2" href="/admin/tests/groups"> + {$t('button.cancel')} + </a> + {#if group} + <button + type="button" + class="btn btn-error btn-outline float-end" + onclick={() => confirm($t('test.groups.deleteConfirm')) && deleteGroup()} + > + {$t('button.delete')} + </button> + {/if} + </div> + </form> +</div> diff --git a/frontend/src/lib/components/tests/TestForm.svelte b/frontend/src/lib/components/tests/TestForm.svelte index 42ee344c5cea16b3202506ccf540f88a67dbe2e1..62116df4f27720d39b80855eee1f171ce9b54c6e 100644 --- a/frontend/src/lib/components/tests/TestForm.svelte +++ b/frontend/src/lib/components/tests/TestForm.svelte @@ -1,11 +1,19 @@ <script lang="ts"> - import type { Test, TestTyping } from '$lib/types/tests'; + import { TestTask, type Test, type TestTyping } from '$lib/types/tests'; import { t } from '$lib/services/i18n'; import autosize from 'svelte-autosize'; + import Draggable from '$lib/components/utils/Draggable.svelte'; + import type TestTaskGroup from '$lib/types/testTaskGroups'; - const { test }: { test: Test | null } = $props(); + const { + test, + possibleGroups, + message + }: { test: Test | null; possibleGroups: TestTaskGroup[]; message: string } = $props(); let type = $state(test?.type); + let groups = $state(test && test instanceof TestTask ? [...test.groups] : []); + let selectedGroup = $state<TestTaskGroup | null>(null); async function deleteTest() { if (!test) return; @@ -19,6 +27,12 @@ {$t(test ? 'tests.title.edit' : 'tests.title.create')} </h2> + {#if message} + <div class="alert alert-error shadow-lg mb-4"> + {message} + </div> + {/if} + <form method="POST"> <label for="title" class="label text-sm">{$t('tests.label.title')} *</label> <input @@ -79,7 +93,29 @@ min="0" value={testTyping?.duration || 60} /> - {:else}{/if} + {:else if type === 'task'} + <h3 class="py-2 px-1 capitalize">{$t('utils.words.groups')}</h3> + <Draggable name="groups[]" bind:items={groups} /> + <div class="flex"> + <select class="select select-bordered flex-grow" bind:value={selectedGroup}> + {#each possibleGroups as group} + <option value={group}> + {group.title} - {group.questions.length} questions + </option> + {/each} + </select> + <button + class="ml-2 button" + onclick={(e) => { + e.preventDefault(); + if (selectedGroup === undefined || selectedGroup === null) return; + groups = [...groups, selectedGroup]; + }} + > + + + </button> + </div> + {/if} <input type="hidden" name="id" value={test?.id} /> diff --git a/frontend/src/lib/components/studies/Draggable.svelte b/frontend/src/lib/components/utils/Draggable.svelte similarity index 87% rename from frontend/src/lib/components/studies/Draggable.svelte rename to frontend/src/lib/components/utils/Draggable.svelte index 95fc21e0e99ec7800650decb359e9ce76a15658b..6f8ca8ac284dd9405202d7f6e1e86d9fdb945f4a 100644 --- a/frontend/src/lib/components/studies/Draggable.svelte +++ b/frontend/src/lib/components/utils/Draggable.svelte @@ -1,6 +1,8 @@ <script lang="ts"> import { t } from '$lib/services/i18n'; import { TestTask, TestTyping } from '$lib/types/tests'; + import TestTaskGroup from '$lib/types/testTaskGroups'; + import { TestTaskQuestionGapfill, TestTaskQuestionQcm } from '$lib/types/testTaskQuestions'; let { items = $bindable(), name } = $props(); let draggedIndex: number | null = $state(null); @@ -54,6 +56,14 @@ {$t('utils.words.questions')}) {:else if item instanceof TestTyping} {item.title} + {:else if item instanceof TestTaskGroup} + {item.title} + ({item.questions.length} + {$t('utils.words.questions')}) + {:else if item instanceof TestTaskQuestionQcm} + {@html item.value} + {:else if item instanceof TestTaskQuestionGapfill} + {item.value} {/if} </div> <div diff --git a/frontend/src/lib/types/testTaskQuestions.ts b/frontend/src/lib/types/testTaskQuestions.ts index afa027fa27cd563015c74d5b87edfbedf18b211c..2a2860fe6dfd672f0590de0ce699fc983500c310 100644 --- a/frontend/src/lib/types/testTaskQuestions.ts +++ b/frontend/src/lib/types/testTaskQuestions.ts @@ -17,6 +17,15 @@ export abstract class TestTaskQuestion { return this._question; } + get type(): string { + if (this instanceof TestTaskQuestionQcm) { + return 'qcm'; + } else if (this instanceof TestTaskQuestionGapfill) { + return 'gapfill'; + } + return 'unknown'; + } + static parse(data: any): TestTaskQuestion | null { if (data === null) { return null; @@ -76,7 +85,7 @@ export class TestTaskQuestionQcm extends TestTaskQuestion { return this._correct; } - get type(): TestTaskQuestionQcmType | null { + get subType(): TestTaskQuestionQcmType | null { switch (this.question.split(':')[0]) { case 'image': return TestTaskQuestionQcmType.image; @@ -138,6 +147,10 @@ export class TestTaskQuestionGapfill extends TestTaskQuestion { return parts; } + get value(): string { + return super.question.split(':').slice(1).join(':'); + } + static parse(data: any): TestTaskQuestionGapfill | null { if (data === null) { return null; diff --git a/frontend/src/routes/admin/tests/+page.svelte b/frontend/src/routes/admin/tests/+page.svelte index 7c1f6fa3b9e36545713b6a9880dd6874addc2c0e..117b2364d0765181caecf653b0990867d96174a9 100644 --- a/frontend/src/routes/admin/tests/+page.svelte +++ b/frontend/src/routes/admin/tests/+page.svelte @@ -33,5 +33,8 @@ </table> <div class="mt-8 mx-auto w-[64rem] flex justify-between"> <a class="button" href="/admin/tests/new">{$t('tests.create')}</a> - <a class="btn" href="/admin/studies">⎠{$t('tests.backtostudies')}</a> + <span> + <a class="btn" href="/admin/studies">⎠{$t('tests.backtostudies')}</a> + <a class="btn" href="/admin/tests/groups">{$t('tests.groups.manage')}</a> + </span> </div> diff --git a/frontend/src/routes/admin/tests/[id]/+page.server.ts b/frontend/src/routes/admin/tests/[id]/+page.server.ts index 5ecbb67f23bc3139a80d8d2a2cfb3eede42a23a7..9305175ac90ffa43b2eca215bfb70ed98938108f 100644 --- a/frontend/src/routes/admin/tests/[id]/+page.server.ts +++ b/frontend/src/routes/admin/tests/[id]/+page.server.ts @@ -1,5 +1,5 @@ import { redirect, type Actions } from '@sveltejs/kit'; -import { updateTestTypingAPI } from '$lib/api/tests'; +import { updateTestTaskAPI, updateTestTypingAPI } from '$lib/api/tests'; export const actions: Actions = { default: async ({ request, fetch }) => { @@ -52,6 +52,21 @@ export const actions: Actions = { }; } + return redirect(303, `/admin/tests`); + } else if (type === 'task') { + const groups = formData + .getAll('groups[]') + .map((group) => parseInt(group.toString(), 10)) + .filter((group) => !isNaN(group)); + + const ok = await updateTestTaskAPI(fetch, id, title, groups); + + if (!ok) { + return { + message: 'Invalid request: Failed to update test' + }; + } + return redirect(303, `/admin/tests`); } } diff --git a/frontend/src/routes/admin/tests/[id]/+page.svelte b/frontend/src/routes/admin/tests/[id]/+page.svelte index d3e6ef7c67755d69e05d3e82fc5cdbb2a12b649c..d6f2975165bf05074da7f689bbb105032079105f 100644 --- a/frontend/src/routes/admin/tests/[id]/+page.svelte +++ b/frontend/src/routes/admin/tests/[id]/+page.svelte @@ -2,8 +2,8 @@ import TestForm from '$lib/components/tests/TestForm.svelte'; import type { PageData } from './$types'; - const { data }: { data: PageData } = $props(); - const { test } = data; + const { data, form }: { data: PageData; form: FormData } = $props(); + const { test, possibleGroups } = data; </script> -<TestForm {test} /> +<TestForm {test} {possibleGroups} message={form?.message} /> diff --git a/frontend/src/routes/admin/tests/[id]/+page.ts b/frontend/src/routes/admin/tests/[id]/+page.ts index 71076a850d6299a3784de794ffdd803c240baa2c..c49f9ecbdebc98feff80f8cb59ee5c82d38e1f90 100644 --- a/frontend/src/routes/admin/tests/[id]/+page.ts +++ b/frontend/src/routes/admin/tests/[id]/+page.ts @@ -1,4 +1,6 @@ +import { getTestAPI, getTestGroupsAPI } from '$lib/api/tests'; import { Test } from '$lib/types/tests'; +import TestTaskGroup from '$lib/types/testTaskGroups'; import { error, type Load } from '@sveltejs/kit'; export const load: Load = async ({ fetch, params }) => { @@ -8,8 +10,11 @@ export const load: Load = async ({ fetch, params }) => { return error(400, 'Invalid ID'); } - const testRaw = await (await fetch(`/api/tests/${id}`)).json(); + const testRaw = await getTestAPI(fetch, id); const test = Test.parse(testRaw); - return { test }; + const groupsRaw = await getTestGroupsAPI(fetch); + const groups = TestTaskGroup.parseAll(groupsRaw); + + return { test, possibleGroups: groups }; }; diff --git a/frontend/src/routes/admin/tests/groups/+page.svelte b/frontend/src/routes/admin/tests/groups/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..b1b79de37821cf205a2722de992f2c8bb32b6b93 --- /dev/null +++ b/frontend/src/routes/admin/tests/groups/+page.svelte @@ -0,0 +1,44 @@ +<script lang="ts"> + import { t } from '$lib/services/i18n'; + import type { PageData } from './$types'; + import type TestTaskGroup from '$lib/types/testTaskGroups'; + + const { data }: { data: PageData } = $props(); + + let groups: TestTaskGroup[] = $state(data.groups); +</script> + +<h1 class="text-xl font-bold m-5 text-center">{$t('header.admin.groups')}</h1> + +<table class="table max-w-5xl mx-auto text-left"> + <thead> + <tr> + <th>#</th> + <th>{$t('utils.words.title')}</th> + <th>{$t('utils.words.demo')}</th> + <th>{$t('utils.words.randomize')}</th> + <th class="capitalize"># {$t('utils.words.questions')}</th> + </tr> + </thead> + <tbody> + {#each groups as group (group.id)} + <tr + class="hover:bg-gray-100 hover:cursor-pointer" + onclick={() => (window.location.href = `/admin/tests/groups/${group.id}`)} + > + <td>{group.id}</td> + <td>{group.title}</td> + <td>{$t(`utils.bool.${group.demo}`)}</td> + <td>{$t(`utils.bool.${group.randomize}`)}</td> + <td>{group.questions.length}</td> + </tr> + {/each} + </tbody> +</table> +<div class="mt-8 mx-auto w-[64rem] flex justify-between"> + <a class="button" href="/admin/tests/groups/new">{$t('tests.groups.create')}</a> + <span> + <a class="btn" href="/admin/tests">⎠{$t('tests.groups.backtotests')}</a> + <a class="btn" href="/admin/tests/groups/questions">{$t('tests.questions.manage')}</a> + </span> +</div> diff --git a/frontend/src/routes/admin/tests/groups/+page.ts b/frontend/src/routes/admin/tests/groups/+page.ts new file mode 100644 index 0000000000000000000000000000000000000000..c6103e67cc138f5fe092cc706dd922ed58f7e5f9 --- /dev/null +++ b/frontend/src/routes/admin/tests/groups/+page.ts @@ -0,0 +1,12 @@ +import { getTestGroupsAPI } from '$lib/api/tests'; +import { type Load } from '@sveltejs/kit'; +import TestTaskGroup from '$lib/types/testTaskGroups'; + +export const load: Load = async ({ fetch }) => { + const groupsRaw = await getTestGroupsAPI(fetch); + const groups = TestTaskGroup.parseAll(groupsRaw); + + return { + groups + }; +}; diff --git a/frontend/src/routes/admin/tests/groups/[id]/+page.server.ts b/frontend/src/routes/admin/tests/groups/[id]/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ecbb67f23bc3139a80d8d2a2cfb3eede42a23a7 --- /dev/null +++ b/frontend/src/routes/admin/tests/groups/[id]/+page.server.ts @@ -0,0 +1,58 @@ +import { redirect, type Actions } from '@sveltejs/kit'; +import { updateTestTypingAPI } from '$lib/api/tests'; + +export const actions: Actions = { + default: async ({ request, fetch }) => { + const formData = await request.formData(); + + const idStr = formData.get('id')?.toString(); + const title = formData.get('title')?.toString(); + const type = formData.get('type')?.toString(); + + if (!title || !type || !idStr || (type !== 'typing' && type !== 'task')) { + return { + message: 'Invalid request: Missing required fields' + }; + } + + const id = parseInt(idStr, 10); + + if (isNaN(id)) { + return { + message: 'Invalid request: Invalid ID' + }; + } + + if (type === 'typing') { + const explanation = formData.get('explanation')?.toString(); + const text = formData.get('text')?.toString(); + const repeatStr = formData.get('repeat')?.toString(); + const durationStr = formData.get('duration')?.toString(); + + if (!explanation || !text || !repeatStr || !durationStr) { + return { + message: 'Invalid request: Missing required fields' + }; + } + + const repeat = parseInt(repeatStr, 10); + const duration = parseInt(durationStr, 10); + + if (isNaN(repeat) || isNaN(duration)) { + return { + message: 'Invalid request: Invalid format for numbers' + }; + } + + const ok = await updateTestTypingAPI(fetch, id, title, explanation, text, repeat, duration); + + if (!ok) { + return { + message: 'Invalid request: Failed to update test' + }; + } + + return redirect(303, `/admin/tests`); + } + } +}; diff --git a/frontend/src/routes/admin/tests/groups/[id]/+page.svelte b/frontend/src/routes/admin/tests/groups/[id]/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..97ba7436319082212d3affbc96851cfd510d15bb --- /dev/null +++ b/frontend/src/routes/admin/tests/groups/[id]/+page.svelte @@ -0,0 +1,9 @@ +<script lang="ts"> + import TestForm from '$lib/components/tests/TestForm.svelte'; + import type { PageData } from './$types'; + + const { data }: { data: PageData } = $props(); + const { test, possibleGroups } = data; +</script> + +<TestForm {test} {possibleGroups} /> diff --git a/frontend/src/routes/admin/tests/groups/[id]/+page.ts b/frontend/src/routes/admin/tests/groups/[id]/+page.ts new file mode 100644 index 0000000000000000000000000000000000000000..c49f9ecbdebc98feff80f8cb59ee5c82d38e1f90 --- /dev/null +++ b/frontend/src/routes/admin/tests/groups/[id]/+page.ts @@ -0,0 +1,20 @@ +import { getTestAPI, getTestGroupsAPI } from '$lib/api/tests'; +import { Test } from '$lib/types/tests'; +import TestTaskGroup from '$lib/types/testTaskGroups'; +import { error, type Load } from '@sveltejs/kit'; + +export const load: Load = async ({ fetch, params }) => { + const id = Number(params.id); + + if (isNaN(id)) { + return error(400, 'Invalid ID'); + } + + const testRaw = await getTestAPI(fetch, id); + const test = Test.parse(testRaw); + + const groupsRaw = await getTestGroupsAPI(fetch); + const groups = TestTaskGroup.parseAll(groupsRaw); + + return { test, possibleGroups: groups }; +}; diff --git a/frontend/src/routes/admin/tests/groups/new/+page.server.ts b/frontend/src/routes/admin/tests/groups/new/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..2fe5e2218cd89c20a8fac9bfa9c8eec46bd4e196 --- /dev/null +++ b/frontend/src/routes/admin/tests/groups/new/+page.server.ts @@ -0,0 +1,27 @@ +import { redirect, type Actions } from '@sveltejs/kit'; +import { createTestTypingAPI } from '$lib/api/tests'; + +export const actions: Actions = { + default: async ({ request, fetch }) => { + const formData = await request.formData(); + + const title = formData.get('title')?.toString(); + const demo = formData.get('demo')?.toString() == 'on'; + const randomize = formData.get('randomize')?.toString() == 'on'; + + if (!title || !demo || !randomize) { + return { + message: 'Invalid request: Missing required fields' + }; + } + + const questions = formData + .getAll('questions[]') + .map((question) => parseInt(question.toString(), 10)) + .filter((question) => !isNaN(question)); + + const id = await createTestTaskGroupAPI(fetch, title, demo, randomize, questions); + + return redirect(303, `/admin/tests`); + } +}; diff --git a/frontend/src/routes/admin/tests/groups/new/+page.svelte b/frontend/src/routes/admin/tests/groups/new/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..4c3987e3c76401719759a7f0c44789e9f314512c --- /dev/null +++ b/frontend/src/routes/admin/tests/groups/new/+page.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import GroupForm from '$lib/components/tests/GroupForm.svelte'; + const { data, form }: { data: PageData; form: FormData } = $props(); + const { possibleQuestions } = data; +</script> + +<GroupForm group={null} {possibleQuestions} message={form?.message} /> diff --git a/frontend/src/routes/admin/tests/groups/new/+page.ts b/frontend/src/routes/admin/tests/groups/new/+page.ts new file mode 100644 index 0000000000000000000000000000000000000000..fcf5f18b9cacebac5116425d6d3c1619cc698065 --- /dev/null +++ b/frontend/src/routes/admin/tests/groups/new/+page.ts @@ -0,0 +1,10 @@ +import { getTestQuestionsAPI } from '$lib/api/tests'; +import { TestTaskQuestion } from '$lib/types/testTaskQuestions'; +import { type Load } from '@sveltejs/kit'; + +export const load: Load = async ({ fetch }) => { + const questionsRaw = await getTestQuestionsAPI(fetch); + const questions = TestTaskQuestion.parseAll(questionsRaw); + + return { possibleQuestions: questions }; +}; diff --git a/frontend/src/routes/admin/tests/new/+page.server.ts b/frontend/src/routes/admin/tests/new/+page.server.ts index fd629c3d9571a485ee5e974abcef3f6fe785dded..7cc2b4f5f13481357e10eb15d44616aee216b67e 100644 --- a/frontend/src/routes/admin/tests/new/+page.server.ts +++ b/frontend/src/routes/admin/tests/new/+page.server.ts @@ -1,5 +1,5 @@ import { redirect, type Actions } from '@sveltejs/kit'; -import { createTestTypingAPI } from '$lib/api/tests'; +import { createTestTaskAPI, createTestTypingAPI } from '$lib/api/tests'; export const actions: Actions = { default: async ({ request, fetch }) => { @@ -43,6 +43,21 @@ export const actions: Actions = { }; } + return redirect(303, `/admin/tests`); + } else if (type === 'task') { + const groups = formData + .getAll('groups[]') + .map((group) => parseInt(group.toString(), 10)) + .filter((group) => !isNaN(group)); + + const id = await createTestTaskAPI(fetch, title, groups); + + if (id === null) { + return { + message: 'Invalid request: Failed to create test' + }; + } + return redirect(303, `/admin/tests`); } } diff --git a/frontend/src/routes/admin/tests/new/+page.svelte b/frontend/src/routes/admin/tests/new/+page.svelte index e62bf17d7b045c4057ff603f653be1ccd15be49f..7cdcedfb06d7f392cad0af12dc07966f558c88a2 100644 --- a/frontend/src/routes/admin/tests/new/+page.svelte +++ b/frontend/src/routes/admin/tests/new/+page.svelte @@ -1,5 +1,7 @@ <script lang="ts"> import TestForm from '$lib/components/tests/TestForm.svelte'; + const { data, form }: { data: PageData; form: FormData } = $props(); + const { possibleGroups } = data; </script> -<TestForm test={null} /> +<TestForm test={null} {possibleGroups} message={form?.message} /> diff --git a/frontend/src/routes/admin/tests/new/+page.ts b/frontend/src/routes/admin/tests/new/+page.ts new file mode 100644 index 0000000000000000000000000000000000000000..f56d77dafbe4bdee1d89ce69ff99a0528057f5df --- /dev/null +++ b/frontend/src/routes/admin/tests/new/+page.ts @@ -0,0 +1,10 @@ +import { getTestGroupsAPI } from '$lib/api/tests'; +import TestTaskGroup from '$lib/types/testTaskGroups'; +import { type Load } from '@sveltejs/kit'; + +export const load: Load = async ({ fetch }) => { + const groupsRaw = await getTestGroupsAPI(fetch); + const groups = TestTaskGroup.parseAll(groupsRaw); + + return { possibleGroups: groups }; +};