From 612e25f16eae272514dd3a896157eebce5d90981 Mon Sep 17 00:00:00 2001 From: Brieuc Dubois <git@bhasher.com> Date: Sun, 6 Apr 2025 13:48:13 +0200 Subject: [PATCH] View, create and edit (typing) tests --- backend/app/crud/tests.py | 24 ++++ backend/app/routes/tests.py | 15 +++ frontend/src/lang/fr.json | 25 ++++- frontend/src/lib/api/tests.ts | 60 ++++++++++ .../src/lib/components/tests/TestForm.svelte | 104 ++++++++++++++++++ frontend/src/lib/types/tests.ts | 27 ++++- .../src/routes/admin/studies/+page.svelte | 3 +- .../routes/admin/studies/new/+page.server.ts | 1 - frontend/src/routes/admin/tests/+page.svelte | 37 +++++++ frontend/src/routes/admin/tests/+page.ts | 11 ++ .../routes/admin/tests/[id]/+page.server.ts | 58 ++++++++++ .../src/routes/admin/tests/[id]/+page.svelte | 9 ++ frontend/src/routes/admin/tests/[id]/+page.ts | 15 +++ .../routes/admin/tests/new/+page.server.ts | 49 +++++++++ .../src/routes/admin/tests/new/+page.svelte | 5 + 15 files changed, 431 insertions(+), 12 deletions(-) create mode 100644 frontend/src/lib/components/tests/TestForm.svelte create mode 100644 frontend/src/routes/admin/tests/+page.svelte create mode 100644 frontend/src/routes/admin/tests/+page.ts create mode 100644 frontend/src/routes/admin/tests/[id]/+page.server.ts create mode 100644 frontend/src/routes/admin/tests/[id]/+page.svelte create mode 100644 frontend/src/routes/admin/tests/[id]/+page.ts create mode 100644 frontend/src/routes/admin/tests/new/+page.server.ts create mode 100644 frontend/src/routes/admin/tests/new/+page.svelte diff --git a/backend/app/crud/tests.py b/backend/app/crud/tests.py index d3297ff0..112bdc7f 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 f310d546..6f63bb74 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 8136121e..7a5dd496 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 54a3fe1f..06e1a9fe 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 00000000..42ee344c --- /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 e71ef0a1..c59d9f48 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 254ee148..7d9be1f2 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 09b23ed3..59965b80 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 00000000..7c1f6fa3 --- /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 00000000..c8643713 --- /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 00000000..5ecbb67f --- /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 00000000..d3e6ef7c --- /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 00000000..71076a85 --- /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 00000000..fd629c3d --- /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 00000000..e62bf17d --- /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} /> -- GitLab