diff --git a/backend/app/crud/tests.py b/backend/app/crud/tests.py index d3297ff054e327a2e574ae72727dff7309defa40..112bdc7f88e87fbe0a2c1591b3d5bbfc016a723f 100644 --- a/backend/app/crud/tests.py +++ b/backend/app/crud/tests.py @@ -13,6 +13,30 @@ def create_test(db: Session, test: schemas.TestCreate) -> models.Test: return db_test +def update_test(db: Session, test: schemas.TestCreate, test_id: int) -> None: + db.query(models.Test).filter(models.Test.id == test_id).update( + {**test.model_dump(exclude_unset=True, exclude={"test_typing", "test_task"})} + ) + + if test.test_typing: + db.query(models.TestTyping).filter(models.TestTyping.test_id == test_id).update( + {**test.test_typing.model_dump(exclude_unset=True)} + ) + else: + db.query(models.TestTyping).filter( + models.TestTyping.test_id == test_id + ).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)} + ) + else: + db.query(models.TestTask).filter(models.TestTask.test_id == test_id).delete() + + db.commit() + + def get_tests(db: Session, skip: int = 0) -> list[models.Test]: return db.query(models.Test).offset(skip).all() diff --git a/backend/app/routes/tests.py b/backend/app/routes/tests.py index f310d546cfad88c57636aad32c2d14aaa6ac4c26..6f63bb74646730d476faeae284e01b449407f37b 100644 --- a/backend/app/routes/tests.py +++ b/backend/app/routes/tests.py @@ -19,6 +19,21 @@ def create_test( return crud.create_test(db, test).id +@require_admin("You do not have permission to update a test.") +@testRouter.put("/{test_id}", status_code=status.HTTP_204_NO_CONTENT) +def update_test( + test_id: int, + test: schemas.TestCreate, + db: Session = Depends(get_db), +): + db_test = crud.get_test(db, test_id) + if db_test is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Test not found" + ) + crud.update_test(db, test, test_id) + + @testRouter.get("") def get_tests( skip: int = 0, diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json index 8136121e3ac9a7486fa2913320c466af4a30731c..7a5dd49695767d0e1161a7e808b6d5c79ed8e452 100644 --- a/frontend/src/lang/fr.json +++ b/frontend/src/lang/fr.json @@ -12,7 +12,8 @@ "admin": { "users": "Utilisateurs", "sessions": "Sessions", - "studies": "Études" + "studies": "Études", + "tests": "Tests" } }, "chatbox": { @@ -398,7 +399,22 @@ }, "tests": { "taskTests": "Tests de langue", - "typingTests": "Tests de frappe" + "typingTests": "Tests de frappe", + "manage": "Gerer les tests", + "create": "Créer un nouveau test", + "backtostudies": "Revenir aux études", + "title": { + "edit": "Modifier le test", + "create": "Créer un test" + }, + "label": { + "title": "Titre", + "type": "Type", + "explanation": "Explication", + "text": "Contenu", + "repeat": "Nombre de répétitions", + "duration": "Durée" + } }, "button": { "create": "Créer", @@ -492,7 +508,7 @@ "inProgress": "En cours", "finished": "Terminée", "topics": "Topics", - "title": "Titre de l'étude", + "title": "Titre", "users": "Utilisateurs", "description": "Description", "email": "E-mail", @@ -501,7 +517,8 @@ "questions": "questions", "tests": "tests", "OrganisationUni": "Organisation/Université", - "Address": "Adresse" + "Address": "Adresse", + "type": "Type" } }, "inputs": { diff --git a/frontend/src/lib/api/tests.ts b/frontend/src/lib/api/tests.ts index 54a3fe1f30bfb9e834da7419f01cf39db36a4af0..06e1a9fedcd452fe988f1dcbf6e3e0bd72a609c5 100644 --- a/frontend/src/lib/api/tests.ts +++ b/frontend/src/lib/api/tests.ts @@ -120,3 +120,63 @@ export async function getTestEntriesScoreAPI( return score; } + +export async function createTestTypingAPI( + fetch: fetchType, + title: string, + explanation: string, + text: string, + repeat: number, + duration: number +) { + const response = await fetch(`/api/tests`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + test_typing: { + explanations: explanation, + text, + repeat, + duration + } + }) + }); + if (!response.ok) return null; + const test = await response.json(); + return test.id; +} + +export async function updateTestTypingAPI( + fetch: fetchType, + id: number, + title: string, + explanation: string, + text: string, + repeat: number, + duration: number +): Promise<boolean> { + const response = await fetch(`/api/tests/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + test_typing: { + explanations: explanation, + text, + repeat, + duration + } + }) + }); + + return response.ok; +} + +export async function deleteTestAPI(fetch: fetchType, id: number): Promise<boolean> { + const response = await fetch(`/api/tests/${id}`, { + method: 'DELETE' + }); + if (!response.ok) return false; + return true; +} diff --git a/frontend/src/lib/components/tests/TestForm.svelte b/frontend/src/lib/components/tests/TestForm.svelte new file mode 100644 index 0000000000000000000000000000000000000000..42ee344c5cea16b3202506ccf540f88a67dbe2e1 --- /dev/null +++ b/frontend/src/lib/components/tests/TestForm.svelte @@ -0,0 +1,104 @@ +<script lang="ts"> + import type { Test, TestTyping } from '$lib/types/tests'; + import { t } from '$lib/services/i18n'; + import autosize from 'svelte-autosize'; + + const { test }: { test: Test | null } = $props(); + + let type = $state(test?.type); + + async function deleteTest() { + if (!test) return; + await test?.delete(); + window.location.href = '/admin/tests'; + } +</script> + +<div class="mx-auto w-full max-w-5xl px-4"> + <h2 class="text-xl font-bold m-5 text-center"> + {$t(test ? 'tests.title.edit' : 'tests.title.create')} + </h2> + + <form method="POST"> + <label for="title" class="label text-sm">{$t('tests.label.title')} *</label> + <input + type="text" + class="input w-full" + name="title" + id="title" + placeholder="Title" + value={test?.title} + required + /> + + <label for="type" class="label text-sm">{$t('tests.label.type')} *</label> + <select name="type" id="type" class="select w-full input" bind:value={type}> + <option value="typing">Typing Test</option> + <option value="task">Task</option> + </select> + + {#if type === 'typing'} + {@const testTyping = test as TestTyping | null} + <label for="explanation" class="label text-sm">{$t('tests.label.explanation')} *</label> + <input + class="input w-full" + type="text" + name="explanation" + id="explanation" + placeholder="Explanation" + value={testTyping?.explanations} + required + /> + <label for="text" class="label text-sm">{$t('tests.label.text')} *</label> + <textarea + use:autosize + class="input w-full max-h-52" + id="text" + name="text" + placeholder="Text to type" + value={testTyping?.text} + required + ></textarea> + <label for="repeat" class="label text-sm">{$t('tests.label.repeat')} *</label> + <input + type="number" + class="input w-full" + name="repeat" + id="repeat" + required + min="1" + value={testTyping?.repeat || 1} + /> + <label for="duration" class="label text-sm">{$t('tests.label.duration')} *</label> + <input + type="number" + class="input w-full" + name="duration" + id="duration" + required + min="0" + value={testTyping?.duration || 60} + /> + {:else}{/if} + + <input type="hidden" name="id" value={test?.id} /> + + <div class="mt-4 mb-6"> + <button type="submit" class="button" disabled={!type}> + {test ? $t('button.update') : $t('button.create')} + </button> + <a class="btn btn-outline float-end ml-2" href="/admin/tests"> + {$t('button.cancel')} + </a> + {#if test} + <button + type="button" + class="btn btn-error btn-outline float-end" + onclick={() => confirm($t('test.deleteConfirm')) && deleteTest()} + > + {$t('button.delete')} + </button> + {/if} + </div> + </form> +</div> diff --git a/frontend/src/lib/types/tests.ts b/frontend/src/lib/types/tests.ts index e71ef0a1487686893530609b064a498fafabd51b..c59d9f48ffdafa0c2f5f8526524fe820f340a4eb 100644 --- a/frontend/src/lib/types/tests.ts +++ b/frontend/src/lib/types/tests.ts @@ -1,4 +1,6 @@ +import { deleteTestAPI } from '$lib/api/tests'; import { toastAlert } from '$lib/utils/toasts'; +import type { fetchType } from '$lib/utils/types'; import TestTaskGroup from './testTaskGroups'; export abstract class Test { @@ -18,6 +20,19 @@ export abstract class Test { return this._title; } + get type(): string { + if (this instanceof TestTask) { + return 'task'; + } else if (this instanceof TestTyping) { + return 'typing'; + } + return 'unknown'; + } + + async delete(f: fetchType = fetch): Promise<boolean> { + return await deleteTestAPI(f, this._id); + } + static parse(data: any): Test | null { if (data === null) { toastAlert('Failed to parse test data'); @@ -79,7 +94,7 @@ export class TestTyping extends Test { private _text: string; private _duration: number; private _repeat: number; - private _explainations: string; + private _explanations: string; constructor( id: number, @@ -87,13 +102,13 @@ export class TestTyping extends Test { text: string, duration: number, repeat: number, - explainations: string + explanations: string ) { super(id, title); this._text = text; this._duration = duration; this._repeat = repeat; - this._explainations = explainations; + this._explanations = explanations; } get text(): string { @@ -108,8 +123,8 @@ export class TestTyping extends Test { return this._repeat; } - get explainations(): string { - return this._explainations; + get explanations(): string { + return this._explanations; } get initialDuration(): number { @@ -143,7 +158,7 @@ export class TestTyping extends Test { data.test_typing.text, data.test_typing.duration, data.test_typing.repeat, - data.test_typing.explainations + data.test_typing.explanations ); } } diff --git a/frontend/src/routes/admin/studies/+page.svelte b/frontend/src/routes/admin/studies/+page.svelte index 254ee148ed71f390f69d5e95839a5fc87ae1454c..7d9be1f23f6743d4d11869b72f0258ad483ee90c 100644 --- a/frontend/src/routes/admin/studies/+page.svelte +++ b/frontend/src/routes/admin/studies/+page.svelte @@ -52,6 +52,7 @@ {/each} </tbody> </table> -<div class="mt-8 w-[64rem] mx-auto"> +<div class="mt-8 mx-auto w-[64rem] flex justify-between"> <a class="button" href="/admin/studies/new">{$t('studies.create')}</a> + <a class="btn" href="/admin/tests">{$t('tests.manage')}</a> </div> diff --git a/frontend/src/routes/admin/studies/new/+page.server.ts b/frontend/src/routes/admin/studies/new/+page.server.ts index 09b23ed3b2a4b41fa63736ac19919fb16a9aa842..59965b80532fff6d392d60e2de3092b574892b91 100644 --- a/frontend/src/routes/admin/studies/new/+page.server.ts +++ b/frontend/src/routes/admin/studies/new/+page.server.ts @@ -103,4 +103,3 @@ export const actions: Actions = { return redirect(303, '/admin/studies'); } }; -null as any as Actions; diff --git a/frontend/src/routes/admin/tests/+page.svelte b/frontend/src/routes/admin/tests/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..7c1f6fa3b9e36545713b6a9880dd6874addc2c0e --- /dev/null +++ b/frontend/src/routes/admin/tests/+page.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import { t } from '$lib/services/i18n'; + import type { PageData } from './$types'; + import type { Test } from '$lib/types/tests'; + + const { data }: { data: PageData } = $props(); + + let tests: Test[] = $state(data.tests); +</script> + +<h1 class="text-xl font-bold m-5 text-center">{$t('header.admin.tests')}</h1> + +<table class="table max-w-5xl mx-auto text-left table-fixed"> + <thead> + <tr> + <th class="w-24">#</th> + <th class="w-32">{$t('utils.words.type')}</th> + <th>{$t('utils.words.title')}</th> + </tr> + </thead> + <tbody> + {#each tests as test (test.id)} + <tr + class="hover:bg-gray-100 hover:cursor-pointer" + onclick={() => (window.location.href = `/admin/tests/${test.id}`)} + > + <td>{test.id}</td> + <td>{test.type}</td> + <td>{test.title}</td> + </tr> + {/each} + </tbody> +</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> +</div> diff --git a/frontend/src/routes/admin/tests/+page.ts b/frontend/src/routes/admin/tests/+page.ts new file mode 100644 index 0000000000000000000000000000000000000000..c8643713a2b0ea63f92abbd4135a63bd0866fe33 --- /dev/null +++ b/frontend/src/routes/admin/tests/+page.ts @@ -0,0 +1,11 @@ +import { getSurveysAPI } from '$lib/api/survey'; +import { type Load } from '@sveltejs/kit'; +import { Test } from '$lib/types/tests'; + +export const load: Load = async ({ fetch }) => { + const tests = Test.parseAll(await getSurveysAPI(fetch)); + + return { + tests + }; +}; diff --git a/frontend/src/routes/admin/tests/[id]/+page.server.ts b/frontend/src/routes/admin/tests/[id]/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ecbb67f23bc3139a80d8d2a2cfb3eede42a23a7 --- /dev/null +++ b/frontend/src/routes/admin/tests/[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/[id]/+page.svelte b/frontend/src/routes/admin/tests/[id]/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..d3e6ef7c67755d69e05d3e82fc5cdbb2a12b649c --- /dev/null +++ b/frontend/src/routes/admin/tests/[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 } = data; +</script> + +<TestForm {test} /> diff --git a/frontend/src/routes/admin/tests/[id]/+page.ts b/frontend/src/routes/admin/tests/[id]/+page.ts new file mode 100644 index 0000000000000000000000000000000000000000..71076a850d6299a3784de794ffdd803c240baa2c --- /dev/null +++ b/frontend/src/routes/admin/tests/[id]/+page.ts @@ -0,0 +1,15 @@ +import { Test } from '$lib/types/tests'; +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 (await fetch(`/api/tests/${id}`)).json(); + const test = Test.parse(testRaw); + + return { test }; +}; diff --git a/frontend/src/routes/admin/tests/new/+page.server.ts b/frontend/src/routes/admin/tests/new/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd629c3d9571a485ee5e974abcef3f6fe785dded --- /dev/null +++ b/frontend/src/routes/admin/tests/new/+page.server.ts @@ -0,0 +1,49 @@ +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 type = formData.get('type')?.toString(); + + if (!title || !type || (type !== 'typing' && type !== 'task')) { + return { + message: 'Invalid request: Missing required fields' + }; + } + + 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 id = await createTestTypingAPI(fetch, title, explanation, text, repeat, duration); + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..e62bf17d7b045c4057ff603f653be1ccd15be49f --- /dev/null +++ b/frontend/src/routes/admin/tests/new/+page.svelte @@ -0,0 +1,5 @@ +<script lang="ts"> + import TestForm from '$lib/components/tests/TestForm.svelte'; +</script> + +<TestForm test={null} />