From d4e60bf94468a8633c8bd04aff90cfb75a56ae76 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Fri, 31 Jan 2025 20:28:24 +0100
Subject: [PATCH 01/44] Full studies UI

---
 backend/app/schemas.py                        |   4 +-
 frontend/src/lang/fr.json                     |   9 +-
 frontend/src/lib/api/survey.ts                |   6 ++
 frontend/src/lib/types/study.ts               |  16 ++-
 frontend/src/lib/types/survey.ts              |   4 +
 .../src/routes/admin/studies/+page.svelte     |  92 ++++++++++++----
 frontend/src/routes/admin/studies/+page.ts    |   6 +-
 .../src/routes/admin/studies/Draggable.svelte | 102 ++++++++++++++++++
 8 files changed, 213 insertions(+), 26 deletions(-)
 create mode 100644 frontend/src/routes/admin/studies/Draggable.svelte

diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index ea40fd01..69aaa94b 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -366,6 +366,7 @@ class Study(BaseModel):
     chat_duration: int
     users: list[User]
     surveys: list[Survey]
+    typing_test: bool
 
 
 class StudyCreate(BaseModel):
@@ -373,4 +374,5 @@ class StudyCreate(BaseModel):
     description: str
     start_date: NaiveDatetime
     end_date: NaiveDatetime
-    chat_duration: int = 30 * 60
+    chat_duration: int = 30
+    typing_test: bool = False
diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json
index efeb4f3b..8a87d44d 100644
--- a/frontend/src/lang/fr.json
+++ b/frontend/src/lang/fr.json
@@ -345,7 +345,7 @@
 		"deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette étude ? Cette action est irréversible.",
 		"startDate": "Date de début",
 		"endDate": "Date de fin",
-		"chatDuration": "Durée des sessions (en secondes)",
+		"chatDuration": "Durée des sessions (en minutes)",
 		"updated": "Étude mise à jour avec succès",
 		"noChanges": "Aucune modification",
 		"updateError": "Erreur lors de la mise à jour de l'étude",
@@ -364,7 +364,8 @@
 		"addUserError": "Erreur lors de l'ajout de l'utilisateur",
 		"addUserSuccess": "Utilisateur ajouté à l'étude",
 		"deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette étude ? Cette action est irréversible.",
-		"createTitle": "Créer une nouvelle étude"
+		"createTitle": "Créer une nouvelle étude",
+		"typingTest": "Activer le test de frappe"
 	},
 	"button": {
 		"create": "Créer",
@@ -462,7 +463,9 @@
 			"users": "Utilisateurs",
 			"description": "Description",
 			"email": "E-mail",
-			"toggle": "Participants"
+			"toggle": "Participants",
+			"groups": "groupes",
+			"questions": "questions"
 		}
 	},
 	"inputs": {
diff --git a/frontend/src/lib/api/survey.ts b/frontend/src/lib/api/survey.ts
index def03af3..ea693447 100644
--- a/frontend/src/lib/api/survey.ts
+++ b/frontend/src/lib/api/survey.ts
@@ -1,5 +1,11 @@
 import type { fetchType } from '$lib/utils/types';
 
+export async function getSurveysAPI(fetch: fetchType) {
+	const response = await fetch('/api/surveys');
+	if (!response.ok) return null;
+	return await response.json();
+}
+
 export async function getSurveyAPI(fetch: fetchType, survey_id: number) {
 	const response = await fetch(`/api/surveys/${survey_id}`);
 	if (!response.ok) return null;
diff --git a/frontend/src/lib/types/study.ts b/frontend/src/lib/types/study.ts
index dd4353d3..c4681871 100644
--- a/frontend/src/lib/types/study.ts
+++ b/frontend/src/lib/types/study.ts
@@ -18,6 +18,7 @@ export default class Study {
 	private _endDate: Date;
 	private _chatDuration: number;
 	private _users: User[];
+	private _typingTest: boolean;
 
 	private constructor(
 		id: number,
@@ -26,7 +27,8 @@ export default class Study {
 		startDate: Date,
 		endDate: Date,
 		chatDuration: number,
-		users: User[]
+		users: User[],
+		typingTest: boolean
 	) {
 		this._id = id;
 		this._title = title;
@@ -35,6 +37,7 @@ export default class Study {
 		this._endDate = endDate;
 		this._chatDuration = chatDuration;
 		this._users = users;
+		this._typingTest = typingTest;
 	}
 
 	get id(): number {
@@ -69,18 +72,23 @@ export default class Study {
 		return this._users.length;
 	}
 
+	get typingTest(): boolean {
+		return this._typingTest;
+	}
+
 	static async create(
 		title: string,
 		description: string,
 		startDate: Date,
 		endDate: Date,
 		chatDuration: number,
+		typingTest: boolean,
 		f: fetchType = fetch
 	): Promise<Study | null> {
 		const id = await createStudyAPI(f, title, description, startDate, endDate, chatDuration);
 
 		if (id) {
-			return new Study(id, title, description, startDate, endDate, chatDuration, []);
+			return new Study(id, title, description, startDate, endDate, chatDuration, [], typingTest);
 		}
 		return null;
 	}
@@ -97,6 +105,7 @@ export default class Study {
 			if (data.start_date) this._startDate = parseToLocalDate(data.start_date);
 			if (data.end_date) this._endDate = parseToLocalDate(data.end_date);
 			if (data.chat_duration) this._chatDuration = data.chat_duration;
+			if (data.typing_test) this._typingTest = data.typing_test;
 			return true;
 		}
 		return false;
@@ -133,7 +142,8 @@ export default class Study {
 			parseToLocalDate(json.start_date),
 			parseToLocalDate(json.end_date),
 			json.chat_duration,
-			[]
+			[],
+			json.typing_test
 		);
 
 		study._users = User.parseAll(json.users);
diff --git a/frontend/src/lib/types/survey.ts b/frontend/src/lib/types/survey.ts
index b1c28ed5..3408a882 100644
--- a/frontend/src/lib/types/survey.ts
+++ b/frontend/src/lib/types/survey.ts
@@ -24,6 +24,10 @@ export default class Survey {
 		return this._groups;
 	}
 
+	get nQuestions(): number {
+		return this._groups.reduce((acc, group) => acc + group.questions.length, 0);
+	}
+
 	static parse(data: any): Survey | null {
 		if (data === null) {
 			toastAlert('Failed to parse survey data');
diff --git a/frontend/src/routes/admin/studies/+page.svelte b/frontend/src/routes/admin/studies/+page.svelte
index 56920d9d..d56aeeba 100644
--- a/frontend/src/routes/admin/studies/+page.svelte
+++ b/frontend/src/routes/admin/studies/+page.svelte
@@ -9,6 +9,8 @@
 	import { Icon, MagnifyingGlass } from 'svelte-hero-icons';
 	import { getUserByEmailAPI } from '$lib/api/users';
 	import type { PageData } from './$types';
+	import Draggable from './Draggable.svelte';
+	import Survey from '$lib/types/survey';
 
 	const { data }: { data: PageData } = $props();
 
@@ -18,10 +20,14 @@
 	let description: string | null = $state(null);
 	let startDate: Date | null = $state(null);
 	let endDate: Date | null = $state(null);
-	let chatDuration: number | null = $state(null);
-	let typingTest: boolean = $state(true);
+	let chatDuration: number = $state(30);
+	let typingTest: boolean = $state(false);
+	let tests: (string | Survey)[] = $state([]);
 
-	let studyCreate: boolean = $state(false);
+	let studyCreate: boolean = $state(true);
+
+	let possibleTests = ['Typing Test', ...data.surveys];
+	let selectedTest: string | Survey | undefined = $state();
 
 	function selectStudy(study: Study | null) {
 		selectedStudy = study;
@@ -30,7 +36,8 @@
 		description = study?.description ?? null;
 		startDate = study?.startDate ?? null;
 		endDate = study?.endDate ?? null;
-		chatDuration = study?.chatDuration ?? null;
+		chatDuration = study?.chatDuration ?? 30;
+		typingTest = study?.typingTest ?? false;
 	}
 
 	async function studyUpdate() {
@@ -51,7 +58,8 @@
 				endDate.getDay() === selectedStudy.endDate.getDay() &&
 				endDate.getMonth() === selectedStudy.endDate.getMonth() &&
 				endDate.getFullYear() === selectedStudy.endDate.getFullYear() &&
-				chatDuration === selectedStudy.chatDuration)
+				chatDuration === selectedStudy.chatDuration &&
+				typingTest === selectedStudy.typingTest)
 		) {
 			selectStudy(null);
 			toastSuccess($t('studies.noChanges'));
@@ -63,7 +71,8 @@
 			description,
 			start_date: formatToUTCDate(startDate),
 			end_date: formatToUTCDate(endDate),
-			chatDuration
+			chat_duration: chatDuration,
+			typing_test: typingTest
 		});
 
 		if (result) {
@@ -88,7 +97,14 @@
 			return;
 		}
 
-		const study = await Study.create(title, description, startDate, endDate, chatDuration);
+		const study = await Study.create(
+			title,
+			description,
+			startDate,
+			endDate,
+			chatDuration,
+			typingTest
+		);
 
 		if (study) {
 			toastSuccess($t('studies.created'));
@@ -212,6 +228,15 @@
 				bind:value={chatDuration}
 				min="0"
 			/>
+			<div class="flex items-center mt-2">
+				<label class="label flex-grow" for="typingTest">{$t('studies.typingTest')} *</label>
+				<input
+					type="checkbox"
+					class="checkbox checkbox-primary size-8"
+					id="typingTest"
+					bind:checked={typingTest}
+				/>
+			</div>
 			<label class="label" for="users">{$t('utils.words.users')}</label>
 			<table class="table">
 				<thead>
@@ -279,18 +304,49 @@
 				bind:value={chatDuration}
 				min="0"
 			/>
-			<label class="label" for="typingTest">{$t('studies.typingTest')} *</label>
-			<input type="checkbox" class="input" id="typingTest" bind:checked={typingTest} />
+			<!--
+			<div class="flex items-center mt-2">
+				<label class="label flex-grow" for="typingTest">{$t('studies.typingTest')} *</label>
+				<input
+					type="checkbox"
+					class="checkbox checkbox-primary size-8"
+					id="typingTest"
+					bind:checked={typingTest}
+				/>
+			</div>
+		-->
+			<h3 class="my-2">{$t('Tests')} *</h3>
+			<Draggable bind:items={tests} />
+			<div class="mt-2 flex">
+				<select class="select select-bordered flex-grow" bind:value={selectedTest}>
+					{#each possibleTests as test}
+						{#if test instanceof Survey}
+							<option value={test}>{test.title}</option>
+						{:else}
+							<option value={test}>{test}</option>
+						{/if}
+					{/each}
+				</select>
+				<button
+					class="ml-2 button"
+					onclick={() => {
+						if (selectedTest === undefined) return;
+						tests = [...tests, selectedTest];
+					}}
+				>
+					+
+				</button>
+			</div>
+			<div class="mt-4">
+				<button class="button" onclick={createStudy}>{$t('button.create')}</button>
+				<button
+					class="btn btn-outline float-end ml-2"
+					onclick={() => (studyCreate = false && selectStudy(null))}
+				>
+					{$t('button.cancel')}
+				</button>
+			</div>
 		</form>
-		<div class="mt-4">
-			<button class="button" onclick={createStudy}>{$t('button.create')}</button>
-			<button
-				class="btn btn-outline float-end ml-2"
-				onclick={() => (studyCreate = false && selectStudy(null))}
-			>
-				{$t('button.cancel')}
-			</button>
-		</div>
 	</div>
 </dialog>
 
diff --git a/frontend/src/routes/admin/studies/+page.ts b/frontend/src/routes/admin/studies/+page.ts
index f4043711..8760658e 100644
--- a/frontend/src/routes/admin/studies/+page.ts
+++ b/frontend/src/routes/admin/studies/+page.ts
@@ -1,11 +1,15 @@
 import { getStudiesAPI } from '$lib/api/studies';
+import { getSurveysAPI } from '$lib/api/survey';
 import Study from '$lib/types/study';
+import Survey from '$lib/types/survey';
 import { type Load } from '@sveltejs/kit';
 
 export const load: Load = async ({ fetch }) => {
 	const studies = Study.parseAll(await getStudiesAPI(fetch));
+	const surveys = Survey.parseAll(await getSurveysAPI(fetch));
 
 	return {
-		studies
+		studies,
+		surveys
 	};
 };
diff --git a/frontend/src/routes/admin/studies/Draggable.svelte b/frontend/src/routes/admin/studies/Draggable.svelte
new file mode 100644
index 00000000..464c4c74
--- /dev/null
+++ b/frontend/src/routes/admin/studies/Draggable.svelte
@@ -0,0 +1,102 @@
+<script lang="ts">
+	import { t } from '$lib/services/i18n';
+	import Survey from '$lib/types/survey';
+
+	let { items = $bindable([]) } = $props();
+
+	let draggedIndex: number | null = $state(null);
+	let overIndex: number | null = $state(null);
+
+	const handleDragStart = (index: number) => {
+		draggedIndex = index;
+	};
+
+	const handleDragOver = (index: number, event: DragEvent) => {
+		event.preventDefault();
+		overIndex = index;
+	};
+
+	const handleDrop = (index: number) => {
+		if (draggedIndex !== null && draggedIndex !== index) {
+			const reordered = [...items];
+			const [removed] = reordered.splice(draggedIndex, 1);
+			reordered.splice(index, 0, removed);
+			items = reordered;
+		}
+		draggedIndex = null;
+		overIndex = null;
+	};
+
+	const handleDragEnd = () => {
+		draggedIndex = null;
+		overIndex = null;
+	};
+
+	const deleteItem = (index: number) => {
+		items = items.filter((_, i) => i !== index);
+	};
+</script>
+
+<ul class="space-y-2">
+	{#each items as item, index}
+		<li
+			class="p-3 bg-gray-200 border rounded-md select-none
+        transition-transform ease-out duration-200 flex
+        {index === draggedIndex ? 'opacity-50 bg-gray-300' : ''}
+        {index === overIndex ? 'border-dashed border-2 border-blue-500' : ''}"
+		>
+			<div class="flex-grow">
+				{#if item instanceof Survey}
+					{item.title} ({item.groups.length}
+					{$t('utils.words.groups')}, {item.nQuestions}
+					{$t('utils.words.questions')})
+				{:else}
+					{item}
+				{/if}
+			</div>
+			<div
+				class="ml-4 flex flex-col gap-1 cursor-grab"
+				draggable="true"
+				ondragstart={() => handleDragStart(index)}
+				ondragover={(e) => handleDragOver(index, e)}
+				ondrop={() => handleDrop(index)}
+				ondragend={handleDragEnd}
+				role="button"
+				tabindex="0"
+			>
+				<div class="flex gap-1">
+					<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
+					<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
+				</div>
+				<div class="flex gap-1">
+					<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
+					<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
+				</div>
+				<div class="flex gap-1">
+					<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
+					<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
+				</div>
+			</div>
+			<button
+				class="ml-4 p-2 bg-red-500 text-white rounded-md hover:bg-red-600"
+				onclick={() => deleteItem(index)}
+				aria-label="Delete"
+			>
+				<svg
+					xmlns="http://www.w3.org/2000/svg"
+					class="h-4 w-4"
+					fill="none"
+					viewBox="0 0 24 24"
+					stroke="currentColor"
+				>
+					<path
+						stroke-linecap="round"
+						stroke-linejoin="round"
+						stroke-width="2"
+						d="M6 18L18 6M6 6l12 12"
+					/>
+				</svg>
+			</button>
+		</li>
+	{/each}
+</ul>
-- 
GitLab


From 04cb2ac4ef390b738ac5fe05ea7e54757d92cc33 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Fri, 7 Feb 2025 18:25:55 +0100
Subject: [PATCH 02/44] Move new study to a new page

---
 frontend/src/lib/types/study.ts               |  16 +--
 .../src/routes/admin/studies/+page.svelte     | 109 +-----------------
 frontend/src/routes/admin/studies/+page.ts    |   6 +-
 .../routes/admin/studies/new/+page.server.ts  |  42 +++++++
 .../src/routes/admin/studies/new/+page.svelte |  60 ++++++++++
 .../src/routes/admin/studies/new/+page.ts     |  11 ++
 .../admin/studies/{ => new}/Draggable.svelte  |   0
 7 files changed, 118 insertions(+), 126 deletions(-)
 create mode 100644 frontend/src/routes/admin/studies/new/+page.server.ts
 create mode 100644 frontend/src/routes/admin/studies/new/+page.svelte
 create mode 100644 frontend/src/routes/admin/studies/new/+page.ts
 rename frontend/src/routes/admin/studies/{ => new}/Draggable.svelte (100%)

diff --git a/frontend/src/lib/types/study.ts b/frontend/src/lib/types/study.ts
index c4681871..dd4353d3 100644
--- a/frontend/src/lib/types/study.ts
+++ b/frontend/src/lib/types/study.ts
@@ -18,7 +18,6 @@ export default class Study {
 	private _endDate: Date;
 	private _chatDuration: number;
 	private _users: User[];
-	private _typingTest: boolean;
 
 	private constructor(
 		id: number,
@@ -27,8 +26,7 @@ export default class Study {
 		startDate: Date,
 		endDate: Date,
 		chatDuration: number,
-		users: User[],
-		typingTest: boolean
+		users: User[]
 	) {
 		this._id = id;
 		this._title = title;
@@ -37,7 +35,6 @@ export default class Study {
 		this._endDate = endDate;
 		this._chatDuration = chatDuration;
 		this._users = users;
-		this._typingTest = typingTest;
 	}
 
 	get id(): number {
@@ -72,23 +69,18 @@ export default class Study {
 		return this._users.length;
 	}
 
-	get typingTest(): boolean {
-		return this._typingTest;
-	}
-
 	static async create(
 		title: string,
 		description: string,
 		startDate: Date,
 		endDate: Date,
 		chatDuration: number,
-		typingTest: boolean,
 		f: fetchType = fetch
 	): Promise<Study | null> {
 		const id = await createStudyAPI(f, title, description, startDate, endDate, chatDuration);
 
 		if (id) {
-			return new Study(id, title, description, startDate, endDate, chatDuration, [], typingTest);
+			return new Study(id, title, description, startDate, endDate, chatDuration, []);
 		}
 		return null;
 	}
@@ -105,7 +97,6 @@ export default class Study {
 			if (data.start_date) this._startDate = parseToLocalDate(data.start_date);
 			if (data.end_date) this._endDate = parseToLocalDate(data.end_date);
 			if (data.chat_duration) this._chatDuration = data.chat_duration;
-			if (data.typing_test) this._typingTest = data.typing_test;
 			return true;
 		}
 		return false;
@@ -142,8 +133,7 @@ export default class Study {
 			parseToLocalDate(json.start_date),
 			parseToLocalDate(json.end_date),
 			json.chat_duration,
-			[],
-			json.typing_test
+			[]
 		);
 
 		study._users = User.parseAll(json.users);
diff --git a/frontend/src/routes/admin/studies/+page.svelte b/frontend/src/routes/admin/studies/+page.svelte
index d56aeeba..d23ec817 100644
--- a/frontend/src/routes/admin/studies/+page.svelte
+++ b/frontend/src/routes/admin/studies/+page.svelte
@@ -9,8 +9,6 @@
 	import { Icon, MagnifyingGlass } from 'svelte-hero-icons';
 	import { getUserByEmailAPI } from '$lib/api/users';
 	import type { PageData } from './$types';
-	import Draggable from './Draggable.svelte';
-	import Survey from '$lib/types/survey';
 
 	const { data }: { data: PageData } = $props();
 
@@ -22,12 +20,6 @@
 	let endDate: Date | null = $state(null);
 	let chatDuration: number = $state(30);
 	let typingTest: boolean = $state(false);
-	let tests: (string | Survey)[] = $state([]);
-
-	let studyCreate: boolean = $state(true);
-
-	let possibleTests = ['Typing Test', ...data.surveys];
-	let selectedTest: string | Survey | undefined = $state();
 
 	function selectStudy(study: Study | null) {
 		selectedStudy = study;
@@ -83,38 +75,6 @@
 		}
 	}
 
-	async function createStudy() {
-		if (
-			title === null ||
-			description === null ||
-			startDate === null ||
-			endDate === null ||
-			chatDuration === null ||
-			title === '' ||
-			description === ''
-		) {
-			toastAlert($t('studies.createMissing'));
-			return;
-		}
-
-		const study = await Study.create(
-			title,
-			description,
-			startDate,
-			endDate,
-			chatDuration,
-			typingTest
-		);
-
-		if (study) {
-			toastSuccess($t('studies.created'));
-			studyCreate = false;
-			studies.push(study);
-		} else {
-			toastAlert($t('studies.createError'));
-		}
-	}
-
 	async function deleteStudy() {
 		if (!selectedStudy) return;
 
@@ -204,7 +164,7 @@
 	</tbody>
 </table>
 <div class="mt-8 w-[64rem] mx-auto">
-	<button class="button" onclick={() => (studyCreate = true)}>{$t('studies.create')}</button>
+	<a class="button" href="/admin/studies/new">{$t('studies.create')}</a>
 </div>
 
 <dialog class="modal bg-black bg-opacity-50" open={selectedStudy != null}>
@@ -283,73 +243,6 @@
 	</div>
 </dialog>
 
-<dialog class="modal bg-black bg-opacity-50" open={studyCreate}>
-	<div class="modal-box max-w-4xl">
-		<h2 class="text-xl font-bold m-5 text-center">{$t('studies.createTitle')}</h2>
-		<form>
-			<label class="label" for="title">{$t('utils.words.title')} *</label>
-			<input class="input w-full" type="text" id="title" bind:value={title} />
-			<label class="label" for="description">{$t('utils.words.description')} *</label>
-			<textarea use:autosize class="input w-full max-h-52" id="title" bind:value={description}>
-			</textarea>
-			<label class="label" for="startDate">{$t('studies.startDate')} *</label>
-			<DateInput class="input w-full" id="startDate" bind:date={startDate} />
-			<label class="label" for="endDate">{$t('studies.endDate')} *</label>
-			<DateInput class="input w-full" id="endDate" bind:date={endDate} />
-			<label class="label" for="chatDuration">{$t('studies.chatDuration')} *</label>
-			<input
-				class="input w-full"
-				type="number"
-				id="chatDuration"
-				bind:value={chatDuration}
-				min="0"
-			/>
-			<!--
-			<div class="flex items-center mt-2">
-				<label class="label flex-grow" for="typingTest">{$t('studies.typingTest')} *</label>
-				<input
-					type="checkbox"
-					class="checkbox checkbox-primary size-8"
-					id="typingTest"
-					bind:checked={typingTest}
-				/>
-			</div>
-		-->
-			<h3 class="my-2">{$t('Tests')} *</h3>
-			<Draggable bind:items={tests} />
-			<div class="mt-2 flex">
-				<select class="select select-bordered flex-grow" bind:value={selectedTest}>
-					{#each possibleTests as test}
-						{#if test instanceof Survey}
-							<option value={test}>{test.title}</option>
-						{:else}
-							<option value={test}>{test}</option>
-						{/if}
-					{/each}
-				</select>
-				<button
-					class="ml-2 button"
-					onclick={() => {
-						if (selectedTest === undefined) return;
-						tests = [...tests, selectedTest];
-					}}
-				>
-					+
-				</button>
-			</div>
-			<div class="mt-4">
-				<button class="button" onclick={createStudy}>{$t('button.create')}</button>
-				<button
-					class="btn btn-outline float-end ml-2"
-					onclick={() => (studyCreate = false && selectStudy(null))}
-				>
-					{$t('button.cancel')}
-				</button>
-			</div>
-		</form>
-	</div>
-</dialog>
-
 <dialog class="modal bg-black bg-opacity-50" open={newUserModal}>
 	<div class="modal-box">
 		<h2 class="text-xl font-bold mb-4">{$t('studies.newUser')}</h2>
diff --git a/frontend/src/routes/admin/studies/+page.ts b/frontend/src/routes/admin/studies/+page.ts
index 8760658e..f4043711 100644
--- a/frontend/src/routes/admin/studies/+page.ts
+++ b/frontend/src/routes/admin/studies/+page.ts
@@ -1,15 +1,11 @@
 import { getStudiesAPI } from '$lib/api/studies';
-import { getSurveysAPI } from '$lib/api/survey';
 import Study from '$lib/types/study';
-import Survey from '$lib/types/survey';
 import { type Load } from '@sveltejs/kit';
 
 export const load: Load = async ({ fetch }) => {
 	const studies = Study.parseAll(await getStudiesAPI(fetch));
-	const surveys = Survey.parseAll(await getSurveysAPI(fetch));
 
 	return {
-		studies,
-		surveys
+		studies
 	};
 };
diff --git a/frontend/src/routes/admin/studies/new/+page.server.ts b/frontend/src/routes/admin/studies/new/+page.server.ts
new file mode 100644
index 00000000..483a1d34
--- /dev/null
+++ b/frontend/src/routes/admin/studies/new/+page.server.ts
@@ -0,0 +1,42 @@
+import { createStudyAPI } from '$lib/api/studies';
+import type { Actions } from '@sveltejs/kit';
+
+export const actions: Actions = {
+	default: async ({ request, fetch }) => {
+		const formData = await request.formData();
+
+		const title = formData.get('title')?.toString();
+		let description = formData.get('description')?.toString();
+		const startDateStr = formData.get('startDate')?.toString();
+		const endDateStr = formData.get('endDate')?.toString();
+		const chatDurationStr = formData.get('chatDuration')?.toString();
+
+		if (!title || !startDateStr || !endDateStr || !chatDurationStr) {
+			return {
+				message: 'Invalid request'
+			};
+		}
+
+		if (description === undefined) {
+			description = '';
+		}
+
+		const startDate = new Date(startDateStr);
+		const endDate = new Date(endDateStr);
+
+		if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
+			return {
+				message: 'Invalid request'
+			};
+		}
+
+		const chatDuration = parseInt(chatDurationStr, 10);
+		if (isNaN(chatDuration)) {
+			return {
+				message: 'Invalid request'
+			};
+		}
+
+		await createStudyAPI(fetch, title, description, startDate, endDate, chatDuration);
+	}
+};
diff --git a/frontend/src/routes/admin/studies/new/+page.svelte b/frontend/src/routes/admin/studies/new/+page.svelte
new file mode 100644
index 00000000..937b80b9
--- /dev/null
+++ b/frontend/src/routes/admin/studies/new/+page.svelte
@@ -0,0 +1,60 @@
+<script lang="ts">
+	import Survey from '$lib/types/survey';
+	import type { PageData } from './$types';
+	import Draggable from './Draggable.svelte';
+	import autosize from 'svelte-autosize';
+	import { t } from '$lib/services/i18n';
+	import DateInput from '$lib/components/utils/dateInput.svelte';
+
+	let { data }: { data: PageData } = $props();
+
+	let tests: (string | Survey)[] = $state([]);
+
+	let possibleTests = ['Typing Test', ...data.surveys];
+	let selectedTest: string | Survey | undefined = $state();
+</script>
+
+<div class="max-w-5xl mx-auto w-full">
+	<h2 class="text-xl font-bold m-5 text-center">{$t('studies.createTitle')}</h2>
+	<form method="post">
+		<label class="label" for="title">{$t('utils.words.title')} *</label>
+		<input class="input w-full" type="text" id="title" />
+		<label class="label" for="description">{$t('utils.words.description')} *</label>
+		<textarea use:autosize class="input w-full max-h-52" id="title"> </textarea>
+		<label class="label" for="startDate">{$t('studies.startDate')} *</label>
+		<DateInput class="input w-full" id="startDate" date={new Date()} />
+		<label class="label" for="endDate">{$t('studies.endDate')} *</label>
+		<DateInput class="input w-full" id="endDate" date={new Date()} />
+		<label class="label" for="chatDuration">{$t('studies.chatDuration')} *</label>
+		<input class="input w-full" type="number" id="chatDuration" min="0" />
+
+		<h3 class="my-2">{$t('Tests')} *</h3>
+		<Draggable bind:items={tests} />
+		<div class="mt-2 flex">
+			<select class="select select-bordered flex-grow" bind:value={selectedTest}>
+				{#each possibleTests as test}
+					{#if test instanceof Survey}
+						<option value={test}>{test.title}</option>
+					{:else}
+						<option value={test}>{test}</option>
+					{/if}
+				{/each}
+			</select>
+			<button
+				class="ml-2 button"
+				onclick={() => {
+					if (selectedTest === undefined) return;
+					tests = [...tests, selectedTest];
+				}}
+			>
+				+
+			</button>
+		</div>
+		<div class="mt-4">
+			<button class="button">{$t('button.create')}</button>
+			<a class="btn btn-outline float-end ml-2" href="/admin/studies">
+				{$t('button.cancel')}
+			</a>
+		</div>
+	</form>
+</div>
diff --git a/frontend/src/routes/admin/studies/new/+page.ts b/frontend/src/routes/admin/studies/new/+page.ts
new file mode 100644
index 00000000..5512da25
--- /dev/null
+++ b/frontend/src/routes/admin/studies/new/+page.ts
@@ -0,0 +1,11 @@
+import { getSurveysAPI } from '$lib/api/survey';
+import Survey from '$lib/types/survey';
+import { type Load } from '@sveltejs/kit';
+
+export const load: Load = async ({ fetch }) => {
+	const surveys = Survey.parseAll(await getSurveysAPI(fetch));
+
+	return {
+		surveys
+	};
+};
diff --git a/frontend/src/routes/admin/studies/Draggable.svelte b/frontend/src/routes/admin/studies/new/Draggable.svelte
similarity index 100%
rename from frontend/src/routes/admin/studies/Draggable.svelte
rename to frontend/src/routes/admin/studies/new/Draggable.svelte
-- 
GitLab


From 05fbea655226e6a140e1d70281b7fb3aafb0b936 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Sun, 9 Feb 2025 17:49:26 +0100
Subject: [PATCH 03/44] Fix new page

---
 backend/app/models.py                         |  9 +++++
 frontend/src/lib/api/studies.ts               |  6 ++-
 .../src/lib/components/utils/dateInput.svelte | 38 ++++++++++++------
 frontend/src/lib/types/study.ts               |  2 +-
 .../src/routes/admin/studies/+page.svelte     |  8 ++--
 .../routes/admin/studies/new/+page.server.ts  | 25 +++++++++---
 .../src/routes/admin/studies/new/+page.svelte | 39 +++++++++++++------
 .../routes/admin/studies/new/Draggable.svelte | 15 ++++++-
 8 files changed, 104 insertions(+), 38 deletions(-)

diff --git a/backend/app/models.py b/backend/app/models.py
index 2ae862f2..09c47a7d 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -297,6 +297,7 @@ class Study(Base):
     surveys = relationship(
         "SurveySurvey", secondary="study_surveys", back_populates="studies"
     )
+    tests = relationship("StudyTests", backref="study")
 
 
 class StudyUser(Base):
@@ -311,3 +312,11 @@ class StudySurvey(Base):
 
     study_id = Column(Integer, ForeignKey("studies.id"), primary_key=True)
     survey_id = Column(Integer, ForeignKey("survey_surveys.id"), primary_key=True)
+
+
+class StudyTests(Base):
+    __tablename__ = "study_tests"
+
+    study_id = Column(Integer, ForeignKey("studies.id"), primary_key=True)
+    type = Column(String)
+    survey_id = Column(Integer, ForeignKey("survey_surveys.id"), nullable=True)
diff --git a/frontend/src/lib/api/studies.ts b/frontend/src/lib/api/studies.ts
index 7eff0b91..846f4491 100644
--- a/frontend/src/lib/api/studies.ts
+++ b/frontend/src/lib/api/studies.ts
@@ -26,7 +26,8 @@ export async function createStudyAPI(
 	description: string,
 	startDate: Date,
 	endDate: Date,
-	chatDuration: number
+	chatDuration: number,
+	tests: { type: string; id?: number }[]
 ): Promise<number | null> {
 	const response = await fetch('/api/studies', {
 		method: 'POST',
@@ -36,7 +37,8 @@ export async function createStudyAPI(
 			description,
 			start_date: formatToUTCDate(startDate),
 			end_date: formatToUTCDate(endDate),
-			chat_duration: chatDuration
+			chat_duration: chatDuration,
+			tests
 		})
 	});
 	if (!response.ok) return null;
diff --git a/frontend/src/lib/components/utils/dateInput.svelte b/frontend/src/lib/components/utils/dateInput.svelte
index 8a3cd516..b2746ab0 100644
--- a/frontend/src/lib/components/utils/dateInput.svelte
+++ b/frontend/src/lib/components/utils/dateInput.svelte
@@ -5,19 +5,35 @@ source: https://svelte.dev/playground/dc963bbead384b69aad17824149d6d27?version=3
 <script lang="ts">
 	import dayjs from 'dayjs';
 
-	export let format = 'YYYY-MM-DD';
-	export let date: Date | null;
-	let class_: string | undefined;
-	export { class_ as class };
-	export let id: string | undefined;
+	let {
+		format = 'YYYY-MM-DD',
+		date,
+		id,
+		name,
+		required = false,
+		class: className
+	} = $props<{
+		format?: string;
+		date: Date | null;
+		id?: string;
+		name?: string;
+		required?: boolean;
+		class?: string;
+	}>();
 
-	let internal: string | null = null;
+	let internal = $state<string | null>(null);
 
-	const input = (x) => (internal = x ? dayjs(x).format(format) : null);
-	const output = (x) => (date = x ? dayjs(x, format).toDate() : null);
+	// Convert the input date to internal string format
+	let formattedDate = $derived(date ? dayjs(date).format(format) : null);
+	$effect(() => {
+		internal = formattedDate;
+	});
 
-	$: input(date);
-	$: output(internal);
+	// Convert internal string back to date
+	let parsedDate = $derived(internal ? dayjs(internal, format).toDate() : null);
+	$effect(() => {
+		date = parsedDate;
+	});
 </script>
 
-<input type="date" bind:value={internal} {id} class={class_} />
+<input type="date" bind:value={internal} {id} {name} class={className} {required} />
diff --git a/frontend/src/lib/types/study.ts b/frontend/src/lib/types/study.ts
index dd4353d3..820e3aa7 100644
--- a/frontend/src/lib/types/study.ts
+++ b/frontend/src/lib/types/study.ts
@@ -77,7 +77,7 @@ export default class Study {
 		chatDuration: number,
 		f: fetchType = fetch
 	): Promise<Study | null> {
-		const id = await createStudyAPI(f, title, description, startDate, endDate, chatDuration);
+		const id = await createStudyAPI(f, title, description, startDate, endDate, chatDuration, []);
 
 		if (id) {
 			return new Study(id, title, description, startDate, endDate, chatDuration, []);
diff --git a/frontend/src/routes/admin/studies/+page.svelte b/frontend/src/routes/admin/studies/+page.svelte
index d23ec817..8ccffe45 100644
--- a/frontend/src/routes/admin/studies/+page.svelte
+++ b/frontend/src/routes/admin/studies/+page.svelte
@@ -29,7 +29,6 @@
 		startDate = study?.startDate ?? null;
 		endDate = study?.endDate ?? null;
 		chatDuration = study?.chatDuration ?? 30;
-		typingTest = study?.typingTest ?? false;
 	}
 
 	async function studyUpdate() {
@@ -50,8 +49,7 @@
 				endDate.getDay() === selectedStudy.endDate.getDay() &&
 				endDate.getMonth() === selectedStudy.endDate.getMonth() &&
 				endDate.getFullYear() === selectedStudy.endDate.getFullYear() &&
-				chatDuration === selectedStudy.chatDuration &&
-				typingTest === selectedStudy.typingTest)
+				chatDuration === selectedStudy.chatDuration)
 		) {
 			selectStudy(null);
 			toastSuccess($t('studies.noChanges'));
@@ -177,9 +175,9 @@
 			<textarea use:autosize class="input w-full max-h-52" id="title" bind:value={description}>
 			</textarea>
 			<label class="label" for="startDate">{$t('studies.startDate')}</label>
-			<DateInput class="input w-full" id="startDate" bind:date={startDate} />
+			<DateInput class="input w-full" id="startDate" date={startDate} />
 			<label class="label" for="endDate">{$t('studies.endDate')}</label>
-			<DateInput class="input w-full" id="endDate" bind:date={endDate} />
+			<DateInput class="input w-full" id="endDate" date={endDate} />
 			<label class="label" for="chatDuration">{$t('studies.chatDuration')}</label>
 			<input
 				class="input w-full"
diff --git a/frontend/src/routes/admin/studies/new/+page.server.ts b/frontend/src/routes/admin/studies/new/+page.server.ts
index 483a1d34..5f1850be 100644
--- a/frontend/src/routes/admin/studies/new/+page.server.ts
+++ b/frontend/src/routes/admin/studies/new/+page.server.ts
@@ -1,5 +1,5 @@
 import { createStudyAPI } from '$lib/api/studies';
-import type { Actions } from '@sveltejs/kit';
+import { redirect, type Actions } from '@sveltejs/kit';
 
 export const actions: Actions = {
 	default: async ({ request, fetch }) => {
@@ -11,9 +11,11 @@ export const actions: Actions = {
 		const endDateStr = formData.get('endDate')?.toString();
 		const chatDurationStr = formData.get('chatDuration')?.toString();
 
+		console.log(title, description, startDateStr, endDateStr, chatDurationStr);
+
 		if (!title || !startDateStr || !endDateStr || !chatDurationStr) {
 			return {
-				message: 'Invalid request'
+				message: 'Invalid request 1'
 			};
 		}
 
@@ -26,17 +28,30 @@ export const actions: Actions = {
 
 		if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
 			return {
-				message: 'Invalid request'
+				message: 'Invalid request 2'
 			};
 		}
 
 		const chatDuration = parseInt(chatDurationStr, 10);
 		if (isNaN(chatDuration)) {
 			return {
-				message: 'Invalid request'
+				message: 'Invalid request 3'
 			};
 		}
 
-		await createStudyAPI(fetch, title, description, startDate, endDate, chatDuration);
+		const tests = formData
+			.getAll('tests')
+			.map((test) => {
+				try {
+					return JSON.parse(test.toString());
+				} catch (e) {
+					return null;
+				}
+			})
+			.filter((test) => test !== null);
+
+		await createStudyAPI(fetch, title, description, startDate, endDate, chatDuration, tests);
+
+		return redirect(303, '/admin/studies');
 	}
 };
diff --git a/frontend/src/routes/admin/studies/new/+page.svelte b/frontend/src/routes/admin/studies/new/+page.svelte
index 937b80b9..0f090898 100644
--- a/frontend/src/routes/admin/studies/new/+page.svelte
+++ b/frontend/src/routes/admin/studies/new/+page.svelte
@@ -1,12 +1,12 @@
 <script lang="ts">
 	import Survey from '$lib/types/survey';
-	import type { PageData } from './$types';
+	import type { PageData, ActionData } from './$types';
 	import Draggable from './Draggable.svelte';
 	import autosize from 'svelte-autosize';
 	import { t } from '$lib/services/i18n';
 	import DateInput from '$lib/components/utils/dateInput.svelte';
 
-	let { data }: { data: PageData } = $props();
+	let { data, form }: { data: PageData; form: ActionData } = $props();
 
 	let tests: (string | Survey)[] = $state([]);
 
@@ -14,22 +14,36 @@
 	let selectedTest: string | Survey | undefined = $state();
 </script>
 
-<div class="max-w-5xl mx-auto w-full">
+<div class="mx-auto w-full max-w-5xl px-4">
 	<h2 class="text-xl font-bold m-5 text-center">{$t('studies.createTitle')}</h2>
+	{#if form?.message}
+		<div class="alert alert-error mb-4">
+			{form.message}
+		</div>
+	{/if}
 	<form method="post">
 		<label class="label" for="title">{$t('utils.words.title')} *</label>
-		<input class="input w-full" type="text" id="title" />
-		<label class="label" for="description">{$t('utils.words.description')} *</label>
-		<textarea use:autosize class="input w-full max-h-52" id="title"> </textarea>
+		<input class="input w-full" type="text" id="title" name="title" required />
+		<label class="label" for="description">{$t('utils.words.description')}</label>
+		<textarea use:autosize class="input w-full max-h-52" id="description" name="description">
+		</textarea>
 		<label class="label" for="startDate">{$t('studies.startDate')} *</label>
-		<DateInput class="input w-full" id="startDate" date={new Date()} />
+		<DateInput class="input w-full" id="startDate" name="startDate" date={new Date()} required />
 		<label class="label" for="endDate">{$t('studies.endDate')} *</label>
-		<DateInput class="input w-full" id="endDate" date={new Date()} />
+		<DateInput class="input w-full" id="endDate" name="endDate" date={new Date()} required />
 		<label class="label" for="chatDuration">{$t('studies.chatDuration')} *</label>
-		<input class="input w-full" type="number" id="chatDuration" min="0" />
+		<input
+			class="input w-full"
+			type="number"
+			id="chatDuration"
+			name="chatDuration"
+			min="0"
+			value="30"
+			required
+		/>
 
-		<h3 class="my-2">{$t('Tests')} *</h3>
-		<Draggable bind:items={tests} />
+		<h3 class="my-2">{$t('Tests')}</h3>
+		<Draggable bind:items={tests} name="tests" />
 		<div class="mt-2 flex">
 			<select class="select select-bordered flex-grow" bind:value={selectedTest}>
 				{#each possibleTests as test}
@@ -42,7 +56,8 @@
 			</select>
 			<button
 				class="ml-2 button"
-				onclick={() => {
+				onclick={(e) => {
+					e.preventDefault();
 					if (selectedTest === undefined) return;
 					tests = [...tests, selectedTest];
 				}}
diff --git a/frontend/src/routes/admin/studies/new/Draggable.svelte b/frontend/src/routes/admin/studies/new/Draggable.svelte
index 464c4c74..3e706471 100644
--- a/frontend/src/routes/admin/studies/new/Draggable.svelte
+++ b/frontend/src/routes/admin/studies/new/Draggable.svelte
@@ -2,7 +2,7 @@
 	import { t } from '$lib/services/i18n';
 	import Survey from '$lib/types/survey';
 
-	let { items = $bindable([]) } = $props();
+	let { items = $bindable([]), name } = $props();
 
 	let draggedIndex: number | null = $state(null);
 	let overIndex: number | null = $state(null);
@@ -38,6 +38,13 @@
 </script>
 
 <ul class="space-y-2">
+	{#each items as item}
+		{#if item instanceof Survey}
+			<input type="hidden" {name} value={JSON.stringify({ type: 'survey', id: item.id })} />
+		{:else}
+			<input type="hidden" {name} value={JSON.stringify({ type: 'typing' })} />
+		{/if}
+	{/each}
 	{#each items as item, index}
 		<li
 			class="p-3 bg-gray-200 border rounded-md select-none
@@ -78,8 +85,12 @@
 				</div>
 			</div>
 			<button
+				type="button"
 				class="ml-4 p-2 bg-red-500 text-white rounded-md hover:bg-red-600"
-				onclick={() => deleteItem(index)}
+				onclick={(e) => {
+					e.preventDefault();
+					deleteItem(index);
+				}}
 				aria-label="Delete"
 			>
 				<svg
-- 
GitLab


From 0a7a6db83e6158bcb197d6bb80eb0ffd2483c204 Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Tue, 11 Feb 2025 16:14:55 +0100
Subject: [PATCH 04/44] remove tab in description input + check coherence in
 date input

---
 frontend/src/routes/admin/studies/new/+page.server.ts | 6 ++++++
 frontend/src/routes/admin/studies/new/+page.svelte    | 4 ++--
 2 files changed, 8 insertions(+), 2 deletions(-)

diff --git a/frontend/src/routes/admin/studies/new/+page.server.ts b/frontend/src/routes/admin/studies/new/+page.server.ts
index 5f1850be..c597172f 100644
--- a/frontend/src/routes/admin/studies/new/+page.server.ts
+++ b/frontend/src/routes/admin/studies/new/+page.server.ts
@@ -39,6 +39,12 @@ export const actions: Actions = {
 			};
 		}
 
+		if (startDate.getTime() > endDate.getTime()) {
+			return {
+				message: 'End time cannot be before start time'
+			};
+		}
+
 		const tests = formData
 			.getAll('tests')
 			.map((test) => {
diff --git a/frontend/src/routes/admin/studies/new/+page.svelte b/frontend/src/routes/admin/studies/new/+page.svelte
index 0f090898..49fd4315 100644
--- a/frontend/src/routes/admin/studies/new/+page.svelte
+++ b/frontend/src/routes/admin/studies/new/+page.svelte
@@ -25,8 +25,8 @@
 		<label class="label" for="title">{$t('utils.words.title')} *</label>
 		<input class="input w-full" type="text" id="title" name="title" required />
 		<label class="label" for="description">{$t('utils.words.description')}</label>
-		<textarea use:autosize class="input w-full max-h-52" id="description" name="description">
-		</textarea>
+		<textarea use:autosize class="input w-full max-h-52" id="description" name="description"
+		></textarea>
 		<label class="label" for="startDate">{$t('studies.startDate')} *</label>
 		<DateInput class="input w-full" id="startDate" name="startDate" date={new Date()} required />
 		<label class="label" for="endDate">{$t('studies.endDate')} *</label>
-- 
GitLab


From 4cab24d38ca15ebe549189de5d4928fb0934d225 Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Wed, 12 Feb 2025 13:59:11 +0100
Subject: [PATCH 05/44] add user + add data about typing test

---
 backend/app/schemas.py                        |   2 -
 frontend/src/lib/types/surveyTyping.svelte.ts |   6 ++
 .../src/routes/admin/studies/new/+page.svelte | 100 +++++++++++++++++-
 .../routes/admin/studies/new/Draggable.svelte |  82 +++++++++-----
 4 files changed, 158 insertions(+), 32 deletions(-)
 create mode 100644 frontend/src/lib/types/surveyTyping.svelte.ts

diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 69aaa94b..52fe3011 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -366,7 +366,6 @@ class Study(BaseModel):
     chat_duration: int
     users: list[User]
     surveys: list[Survey]
-    typing_test: bool
 
 
 class StudyCreate(BaseModel):
@@ -375,4 +374,3 @@ class StudyCreate(BaseModel):
     start_date: NaiveDatetime
     end_date: NaiveDatetime
     chat_duration: int = 30
-    typing_test: bool = False
diff --git a/frontend/src/lib/types/surveyTyping.svelte.ts b/frontend/src/lib/types/surveyTyping.svelte.ts
new file mode 100644
index 00000000..d1a6cb78
--- /dev/null
+++ b/frontend/src/lib/types/surveyTyping.svelte.ts
@@ -0,0 +1,6 @@
+export default class SurveyTypingSvelte {
+	name: string = 'Typing Test';
+	text: string = $state('');
+	repetition: number = $state(0);
+	duration: number = $state(0);
+}
diff --git a/frontend/src/routes/admin/studies/new/+page.svelte b/frontend/src/routes/admin/studies/new/+page.svelte
index 49fd4315..d712f2d9 100644
--- a/frontend/src/routes/admin/studies/new/+page.svelte
+++ b/frontend/src/routes/admin/studies/new/+page.svelte
@@ -1,17 +1,56 @@
 <script lang="ts">
 	import Survey from '$lib/types/survey';
+	import SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
 	import type { PageData, ActionData } from './$types';
 	import Draggable from './Draggable.svelte';
 	import autosize from 'svelte-autosize';
 	import { t } from '$lib/services/i18n';
 	import DateInput from '$lib/components/utils/dateInput.svelte';
+	import { toastAlert, toastWarning } from '$lib/utils/toasts';
+	import { getUserByEmailAPI } from '$lib/api/users';
+	import User from '$lib/types/user';
+	import { Icon, MagnifyingGlass } from 'svelte-hero-icons';
 
 	let { data, form }: { data: PageData; form: ActionData } = $props();
 
-	let tests: (string | Survey)[] = $state([]);
+	let tests: (SurveyTypingSvelte | Survey)[] = $state([]);
 
-	let possibleTests = ['Typing Test', ...data.surveys];
-	let selectedTest: string | Survey | undefined = $state();
+	let typing = $state(new SurveyTypingSvelte());
+
+	let possibleTests = [typing, ...data.surveys];
+	let selectedTest: SurveyTypingSvelte | Survey | undefined = $state();
+
+	let newUsername: string = $state('');
+	let newUserModal = $state(false);
+	let users: User[] = $state([]);
+
+	async function addUser() {
+		newUserModal = true;
+	}
+
+	async function searchUser() {
+		if (!newUsername || !newUsername.includes('@')) {
+			toastWarning($t('studies.invalidEmail'));
+			return;
+		}
+		const userData = await getUserByEmailAPI(fetch, newUsername);
+		if (!userData) {
+			toastWarning($t('studies.userNotFound'));
+			return;
+		}
+		const user = User.parse(userData);
+		if (!user) {
+			toastAlert($t('studies.userNotFound'));
+			return;
+		}
+		users = [...users, user];
+		newUsername = '';
+		newUserModal = false;
+	}
+
+	async function removeUser(user: User) {
+		users = users.filter((u) => u.id !== user.id);
+	}
 </script>
 
 <div class="mx-auto w-full max-w-5xl px-4">
@@ -50,7 +89,7 @@
 					{#if test instanceof Survey}
 						<option value={test}>{test.title}</option>
 					{:else}
-						<option value={test}>{test}</option>
+						<option value={test}>{test.name}</option>
 					{/if}
 				{/each}
 			</select>
@@ -65,6 +104,41 @@
 				+
 			</button>
 		</div>
+
+		<label class="label" for="users">{$t('utils.words.users')}</label>
+		<table class="table">
+			<thead>
+				<tr>
+					<td>#</td>
+					<td>{$t('users.category')}</td>
+					<td>{$t('users.nickname')}</td>
+					<td>{$t('users.email')}</td>
+					<td></td>
+				</tr>
+			</thead>
+			<tbody>
+				{#each users ?? [] as user (user.id)}
+					<tr>
+						<td>{user.id}</td>
+						<td>{$t('users.type.' + user.type)}</td>
+						<td>{user.nickname}</td>
+						<td>{user.email}</td>
+						<td>
+							<button
+								type="button"
+								class="btn btn-sm btn-error text-white"
+								onclick={() => removeUser(user)}
+							>
+								{$t('button.remove')}
+							</button>
+						</td>
+					</tr>
+				{/each}
+			</tbody>
+		</table>
+		<button type="button" class="btn btn-primary block mx-auto" onclick={addUser}>
+			{$t('studies.addUserButton')}
+		</button>
 		<div class="mt-4">
 			<button class="button">{$t('button.create')}</button>
 			<a class="btn btn-outline float-end ml-2" href="/admin/studies">
@@ -73,3 +147,21 @@
 		</div>
 	</form>
 </div>
+<dialog class="modal bg-black bg-opacity-50" open={newUserModal}>
+	<div class="modal-box">
+		<h2 class="text-xl font-bold mb-4">{$t('studies.newUser')}</h2>
+		<div class="w-full flex">
+			<input
+				type="text"
+				placeholder={$t('utils.words.email')}
+				bind:value={newUsername}
+				class="input flex-grow mr-2"
+				onkeypress={(e) => e.key === 'Enter' && searchUser()}
+			/>
+			<button class="button w-16" onclick={searchUser}>
+				<Icon src={MagnifyingGlass} />
+			</button>
+		</div>
+		<button class="btn float-end mt-4" onclick={() => (newUserModal = false)}>Close</button>
+	</div>
+</dialog>
diff --git a/frontend/src/routes/admin/studies/new/Draggable.svelte b/frontend/src/routes/admin/studies/new/Draggable.svelte
index 3e706471..5f15cc31 100644
--- a/frontend/src/routes/admin/studies/new/Draggable.svelte
+++ b/frontend/src/routes/admin/studies/new/Draggable.svelte
@@ -1,9 +1,9 @@
 <script lang="ts">
 	import { t } from '$lib/services/i18n';
 	import Survey from '$lib/types/survey';
+	import autosize from 'svelte-autosize';
 
-	let { items = $bindable([]), name } = $props();
-
+	let { items = $bindable(), name } = $props();
 	let draggedIndex: number | null = $state(null);
 	let overIndex: number | null = $state(null);
 
@@ -58,7 +58,35 @@
 					{$t('utils.words.groups')}, {item.nQuestions}
 					{$t('utils.words.questions')})
 				{:else}
-					{item}
+					<div class="mb-2">{item.name}</div>
+					<label class="label" for="typing_input">Text*</label>
+					<textarea
+						use:autosize
+						class="input w-full max-h-52"
+						id="typing_input"
+						name="typing_input"
+						bind:value={item.text}
+						required
+					></textarea>
+					<div class="flex items-center gap-2">
+						<label class="label" for="typing_repetition">Number of time to repeat</label>
+						<input
+							class="input w-20"
+							type="number"
+							id="typing_repetition"
+							name="typing_repetition"
+							bind:value={item.repetition}
+						/>
+						and/or
+						<label class="label" for="typing_time">Duration (in seconds)</label>
+						<input
+							class="input w-20"
+							type="number"
+							id="typing_time"
+							name="typing_time"
+							bind:value={item.duration}
+						/>
+					</div>
 				{/if}
 			</div>
 			<div
@@ -84,30 +112,32 @@
 					<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
 				</div>
 			</div>
-			<button
-				type="button"
-				class="ml-4 p-2 bg-red-500 text-white rounded-md hover:bg-red-600"
-				onclick={(e) => {
-					e.preventDefault();
-					deleteItem(index);
-				}}
-				aria-label="Delete"
-			>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					class="h-4 w-4"
-					fill="none"
-					viewBox="0 0 24 24"
-					stroke="currentColor"
+			<div>
+				<button
+					type="button"
+					class="ml-4 p-2 bg-red-500 text-white rounded-md hover:bg-red-600"
+					onclick={(e) => {
+						e.preventDefault();
+						deleteItem(index);
+					}}
+					aria-label="Delete"
 				>
-					<path
-						stroke-linecap="round"
-						stroke-linejoin="round"
-						stroke-width="2"
-						d="M6 18L18 6M6 6l12 12"
-					/>
-				</svg>
-			</button>
+					<svg
+						xmlns="http://www.w3.org/2000/svg"
+						class="h-4 w-4"
+						fill="none"
+						viewBox="0 0 24 24"
+						stroke="currentColor"
+					>
+						<path
+							stroke-linecap="round"
+							stroke-linejoin="round"
+							stroke-width="2"
+							d="M6 18L18 6M6 6l12 12"
+						/>
+					</svg>
+				</button>
+			</div>
 		</li>
 	{/each}
 </ul>
-- 
GitLab


From 8cac913faa4e1d9f25be492c295bd4006aa657ae Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Wed, 12 Feb 2025 16:39:25 +0100
Subject: [PATCH 06/44] check box for connection of user required + input for
 the consent form

---
 frontend/src/lang/fr.json                     |  3 +-
 .../src/routes/admin/studies/new/+page.svelte | 47 +++++++++++++++++--
 .../routes/admin/studies/new/Draggable.svelte | 10 ++--
 3 files changed, 50 insertions(+), 10 deletions(-)

diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json
index 8a87d44d..c0ebcdd0 100644
--- a/frontend/src/lang/fr.json
+++ b/frontend/src/lang/fr.json
@@ -365,7 +365,8 @@
 		"addUserSuccess": "Utilisateur ajouté à l'étude",
 		"deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette étude ? Cette action est irréversible.",
 		"createTitle": "Créer une nouvelle étude",
-		"typingTest": "Activer le test de frappe"
+		"typingTest": "Activer le test de frappe",
+		"hasToLoggin": "Exiger que les participants soient inscrits et connectés pour passer le test"
 	},
 	"button": {
 		"create": "Créer",
diff --git a/frontend/src/routes/admin/studies/new/+page.svelte b/frontend/src/routes/admin/studies/new/+page.svelte
index d712f2d9..75aebead 100644
--- a/frontend/src/routes/admin/studies/new/+page.svelte
+++ b/frontend/src/routes/admin/studies/new/+page.svelte
@@ -20,6 +20,8 @@
 	let possibleTests = [typing, ...data.surveys];
 	let selectedTest: SurveyTypingSvelte | Survey | undefined = $state();
 
+	let hasToLoggin: boolean = $state(false);
+
 	let newUsername: string = $state('');
 	let newUserModal = $state(false);
 	let users: User[] = $state([]);
@@ -81,9 +83,9 @@
 			required
 		/>
 
-		<h3 class="my-2">{$t('Tests')}</h3>
+		<h3 class="py-2 px-1">{$t('Tests')}</h3>
 		<Draggable bind:items={tests} name="tests" />
-		<div class="mt-2 flex">
+		<div class="flex">
 			<select class="select select-bordered flex-grow" bind:value={selectedTest}>
 				{#each possibleTests as test}
 					{#if test instanceof Survey}
@@ -105,6 +107,16 @@
 			</button>
 		</div>
 
+		<div class="flex items-center mt-2">
+			<label class="label flex-grow" for="typingTest">{$t('studies.hasToLoggin')}*</label>
+			<input
+				type="checkbox"
+				class="checkbox checkbox-primary size-8"
+				id="typingTest"
+				bind:checked={hasToLoggin}
+			/>
+		</div>
+
 		<label class="label" for="users">{$t('utils.words.users')}</label>
 		<table class="table">
 			<thead>
@@ -136,10 +148,37 @@
 				{/each}
 			</tbody>
 		</table>
-		<button type="button" class="btn btn-primary block mx-auto" onclick={addUser}>
+		<button type="button" class="btn btn-primary block mx-auto mt-3" onclick={addUser}>
 			{$t('studies.addUserButton')}
 		</button>
-		<div class="mt-4">
+
+		<h3 class="py-2 px-1">{$t('register.consent.title')}</h3>
+		<label class="label text-sm" for="consentParticipation"
+			>{$t('register.consent.participation')}</label
+		>
+		<textarea
+			use:autosize
+			class="input w-full max-h-52"
+			id="consentParticipation"
+			name="consentParticipation"
+		></textarea>
+		<label class="label text-sm" for="consentPrivacy">{$t('register.consent.privacy')}</label>
+		<textarea use:autosize class="input w-full max-h-52" id="consentPrivacy" name="consentPrivacy"
+		></textarea>
+		<label class="label text-sm" for="consentRights">{$t('register.consent.rights')}</label>
+		<textarea use:autosize class="input w-full max-h-52" id="consentRights" name="consentRights"
+		></textarea>
+		<label class="label text-sm" for="consentStudyData"
+			>{$t('register.consent.studyData.title')}</label
+		>
+		<textarea
+			use:autosize
+			class="input w-full max-h-52"
+			id="consentStudyData"
+			name="consentStudyData"
+		></textarea>
+
+		<div class="mt-4 mb-6">
 			<button class="button">{$t('button.create')}</button>
 			<a class="btn btn-outline float-end ml-2" href="/admin/studies">
 				{$t('button.cancel')}
diff --git a/frontend/src/routes/admin/studies/new/Draggable.svelte b/frontend/src/routes/admin/studies/new/Draggable.svelte
index 5f15cc31..82a86d6d 100644
--- a/frontend/src/routes/admin/studies/new/Draggable.svelte
+++ b/frontend/src/routes/admin/studies/new/Draggable.svelte
@@ -37,7 +37,7 @@
 	};
 </script>
 
-<ul class="space-y-2">
+<ul class="w-full">
 	{#each items as item}
 		{#if item instanceof Survey}
 			<input type="hidden" {name} value={JSON.stringify({ type: 'survey', id: item.id })} />
@@ -52,23 +52,23 @@
         {index === draggedIndex ? 'opacity-50 bg-gray-300' : ''}
         {index === overIndex ? 'border-dashed border-2 border-blue-500' : ''}"
 		>
-			<div class="flex-grow">
+			<div class="w-full">
 				{#if item instanceof Survey}
 					{item.title} ({item.groups.length}
 					{$t('utils.words.groups')}, {item.nQuestions}
 					{$t('utils.words.questions')})
 				{:else}
 					<div class="mb-2">{item.name}</div>
-					<label class="label" for="typing_input">Text*</label>
+					<label class="label text-sm" for="typing_input">Text*</label>
 					<textarea
 						use:autosize
-						class="input w-full max-h-52"
+						class="input w-full"
 						id="typing_input"
 						name="typing_input"
 						bind:value={item.text}
 						required
 					></textarea>
-					<div class="flex items-center gap-2">
+					<div class="flex flex-wrap items-center gap-2 text-sm">
 						<label class="label" for="typing_repetition">Number of time to repeat</label>
 						<input
 							class="input w-20"
-- 
GitLab


From d02369bde04ba7194d336c31352bdc4058fae077 Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Wed, 12 Feb 2025 17:30:19 +0100
Subject: [PATCH 07/44] correction UI margin when add a test

---
 frontend/src/routes/admin/studies/new/Draggable.svelte | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/frontend/src/routes/admin/studies/new/Draggable.svelte b/frontend/src/routes/admin/studies/new/Draggable.svelte
index 82a86d6d..8776485c 100644
--- a/frontend/src/routes/admin/studies/new/Draggable.svelte
+++ b/frontend/src/routes/admin/studies/new/Draggable.svelte
@@ -48,7 +48,7 @@
 	{#each items as item, index}
 		<li
 			class="p-3 bg-gray-200 border rounded-md select-none
-        transition-transform ease-out duration-200 flex
+        transition-transform ease-out duration-200 flex mb-2
         {index === draggedIndex ? 'opacity-50 bg-gray-300' : ''}
         {index === overIndex ? 'border-dashed border-2 border-blue-500' : ''}"
 		>
-- 
GitLab


From e56c5fad3b2d401b8849a3717f1947016202200b Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Wed, 19 Feb 2025 09:13:04 +0100
Subject: [PATCH 08/44] information note on the typing test

---
 frontend/src/lang/fr.json                            |  7 ++++++-
 .../src/routes/admin/studies/new/Draggable.svelte    | 12 ++++++++----
 2 files changed, 14 insertions(+), 5 deletions(-)

diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json
index c0ebcdd0..00b3dd3a 100644
--- a/frontend/src/lang/fr.json
+++ b/frontend/src/lang/fr.json
@@ -366,7 +366,12 @@
 		"deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette étude ? Cette action est irréversible.",
 		"createTitle": "Créer une nouvelle étude",
 		"typingTest": "Activer le test de frappe",
-		"hasToLoggin": "Exiger que les participants soient inscrits et connectés pour passer le test"
+		"hasToLoggin": "Exiger que les participants soient inscrits et connectés pour passer le test",
+		"andOr": "et/ou",
+		"typingTestDuration": "Durée (en secondes)",
+		"typingTestRepetition": "Nombre de fois à répéter",
+		"typingTestText": "Texte",
+		"typingTestInfoNote": "Si aucune durée n'est fournis le mode \"plus vite que possible\" sera activé."
 	},
 	"button": {
 		"create": "Créer",
diff --git a/frontend/src/routes/admin/studies/new/Draggable.svelte b/frontend/src/routes/admin/studies/new/Draggable.svelte
index 8776485c..2b1a4045 100644
--- a/frontend/src/routes/admin/studies/new/Draggable.svelte
+++ b/frontend/src/routes/admin/studies/new/Draggable.svelte
@@ -59,7 +59,7 @@
 					{$t('utils.words.questions')})
 				{:else}
 					<div class="mb-2">{item.name}</div>
-					<label class="label text-sm" for="typing_input">Text*</label>
+					<label class="label text-sm" for="typing_input">{$t('studies.typingTestText')}*</label>
 					<textarea
 						use:autosize
 						class="input w-full"
@@ -69,7 +69,8 @@
 						required
 					></textarea>
 					<div class="flex flex-wrap items-center gap-2 text-sm">
-						<label class="label" for="typing_repetition">Number of time to repeat</label>
+						<label class="label" for="typing_repetition">{$t('studies.typingTestRepetition')}</label
+						>
 						<input
 							class="input w-20"
 							type="number"
@@ -77,8 +78,8 @@
 							name="typing_repetition"
 							bind:value={item.repetition}
 						/>
-						and/or
-						<label class="label" for="typing_time">Duration (in seconds)</label>
+						{$t('studies.andOr')}
+						<label class="label" for="typing_time">{$t('studies.typingTestDuration')}</label>
 						<input
 							class="input w-20"
 							type="number"
@@ -86,6 +87,9 @@
 							name="typing_time"
 							bind:value={item.duration}
 						/>
+						<div class="tooltip" data-tip={$t('studies.typingTestInfoNote')}>
+							<span class="ml-1 cursor-pointer font-semibold">ⓘ</span>
+						</div>
 					</div>
 				{/if}
 			</div>
-- 
GitLab


From 88fc9f2a2f9bf5ab6d1ecd52d85f65d43001e4f1 Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Wed, 19 Feb 2025 13:43:29 +0100
Subject: [PATCH 09/44] Modification of a study frontend

---
 frontend/src/lang/fr.json                     |   1 +
 .../lib/components/studies/StudyForm.svelte   | 293 ++++++++++++++++++
 .../lib/types/{study.ts => study.svelte.ts}   | 121 +++++++-
 .../src/routes/admin/studies/+page.svelte     |   7 +-
 frontend/src/routes/admin/studies/+page.ts    |   2 +-
 .../routes/admin/studies/[id]/+page.svelte    |  24 ++
 .../src/routes/admin/studies/[id]/+page.ts    |  22 ++
 .../routes/admin/studies/new/+page.server.ts  |   5 +
 .../routes/register/[[studyId]]/+page.svelte  |   2 +-
 .../src/routes/register/[[studyId]]/+page.ts  |   2 +-
 10 files changed, 468 insertions(+), 11 deletions(-)
 create mode 100644 frontend/src/lib/components/studies/StudyForm.svelte
 rename frontend/src/lib/types/{study.ts => study.svelte.ts} (53%)
 create mode 100644 frontend/src/routes/admin/studies/[id]/+page.svelte
 create mode 100644 frontend/src/routes/admin/studies/[id]/+page.ts

diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json
index 00b3dd3a..2d0aed6b 100644
--- a/frontend/src/lang/fr.json
+++ b/frontend/src/lang/fr.json
@@ -365,6 +365,7 @@
 		"addUserSuccess": "Utilisateur ajouté à l'étude",
 		"deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette étude ? Cette action est irréversible.",
 		"createTitle": "Créer une nouvelle étude",
+		"editTitle": "Modification de l'étude",
 		"typingTest": "Activer le test de frappe",
 		"hasToLoggin": "Exiger que les participants soient inscrits et connectés pour passer le test",
 		"andOr": "et/ou",
diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte
new file mode 100644
index 00000000..d81beacd
--- /dev/null
+++ b/frontend/src/lib/components/studies/StudyForm.svelte
@@ -0,0 +1,293 @@
+<script lang="ts">
+	import DateInput from '$lib/components/utils/dateInput.svelte';
+	import Draggable from './Draggable.svelte';
+	import autosize from 'svelte-autosize';
+	import { toastWarning, toastAlert, toastSuccess } from '$lib/utils/toasts';
+	import { getUserByEmailAPI } from '$lib/api/users';
+	import { Icon, MagnifyingGlass } from 'svelte-hero-icons';
+	import { t } from '$lib/services/i18n';
+	import Survey from '$lib/types/survey';
+	import User from '$lib/types/user';
+	import SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
+	import type Study from '$lib/types/study.svelte';
+	import { formatToUTCDate } from '$lib/utils/date';
+
+	let {
+		study = $bindable(),
+		possibleTests,
+		mode
+	}: {
+		study: Study;
+		possibleTests: (Survey | SurveyTypingSvelte)[];
+		mode: string; //"create" or "edit"
+	} = $props();
+
+	let selectedTest: SurveyTypingSvelte | Survey | undefined = $state();
+
+	let hasToLoggin: boolean = $state(false);
+
+	let newUsername: string = $state('');
+	let newUserModal = $state(false);
+	let users: User[] = $state(study.users);
+
+	async function addUser() {
+		newUserModal = true;
+	}
+
+	async function searchUser() {
+		if (!newUsername || !newUsername.includes('@')) {
+			toastWarning($t('studies.invalidEmail'));
+			return;
+		}
+		const userData = await getUserByEmailAPI(fetch, newUsername);
+		if (!userData) {
+			toastWarning($t('studies.userNotFound'));
+			return;
+		}
+		const user = User.parse(userData);
+		if (!user) {
+			toastAlert($t('studies.userNotFound'));
+			return;
+		}
+		users = [...users, user];
+		newUsername = '';
+		newUserModal = false;
+	}
+
+	async function removeUser(user: User) {
+		users = users.filter((u) => u.id !== user.id);
+	}
+
+	async function studyUpdate() {
+		if (!study) return;
+		const result = await study.patch({
+			title: study.title,
+			description: study.description,
+			start_date: formatToUTCDate(study.startDate),
+			end_date: formatToUTCDate(study.endDate),
+			chat_duration: study.chatDuration,
+			tests: study.tests,
+			consent_participation: study.consentParticipation,
+			consent_privacy: study.consentPrivacy,
+			consent_rights: study.consentRights,
+			consent_study_data: study.consentStudyData
+		});
+
+		if (result) {
+			toastSuccess($t('studies.updated'));
+		} else {
+			toastAlert($t('studies.updateError'));
+		}
+		window.location.href = '/admin/studies';
+	}
+
+	async function deleteStudy() {
+		if (!study) return;
+		await study?.delete();
+		window.location.href = '/admin/studies';
+	}
+</script>
+
+<div class="mx-auto w-full max-w-5xl px-4">
+	<h2 class="text-xl font-bold m-5 text-center">
+		{$t(mode === 'create' ? 'studies.createTitle' : 'studies.editTitle')}
+	</h2>
+	<form method="post">
+		<label class="label" for="title">{$t('utils.words.title')} *</label>
+		<input
+			class="input w-full"
+			type="text"
+			id="title"
+			name="title"
+			required
+			bind:value={study.title}
+		/>
+		<label class="label" for="description">{$t('utils.words.description')}</label>
+		<textarea
+			use:autosize
+			class="input w-full max-h-52"
+			id="description"
+			name="description"
+			bind:value={study.description}
+		></textarea>
+		<label class="label" for="startDate">{$t('studies.startDate')} *</label>
+		<DateInput
+			class="input w-full"
+			id="startDate"
+			name="startDate"
+			date={study.startDate}
+			required
+		/>
+		<label class="label" for="endDate">{$t('studies.endDate')} *</label>
+		<DateInput
+			class="input w-full"
+			id="endDate"
+			name="endDate"
+			date={study.endDate || new Date()}
+			required
+		/>
+
+		<!-- Chat Duration -->
+		<label class="label" for="chatDuration">{$t('studies.chatDuration')} *</label>
+		<input
+			class="input w-full"
+			type="number"
+			id="chatDuration"
+			name="chatDuration"
+			min="0"
+			bind:value={study.chatDuration}
+			required
+		/>
+
+		<!-- Tests Section -->
+		<h3 class="py-2 px-1">{$t('Tests')}</h3>
+		<Draggable bind:items={study.tests} name="tests" />
+		<div class="flex">
+			<select class="select select-bordered flex-grow" bind:value={selectedTest}>
+				{#each possibleTests as test}
+					{#if test instanceof Survey}
+						<option value={test}>{test.title}</option>
+					{:else}
+						<option value={test}>{test.name}</option>
+					{/if}
+				{/each}
+			</select>
+			<button
+				class="ml-2 button"
+				onclick={(e) => {
+					e.preventDefault();
+					if (selectedTest === undefined) return;
+					study.tests = [...study.tests, selectedTest];
+				}}
+			>
+				+
+			</button>
+		</div>
+
+		<!-- Login Requirement -->
+		<div class="flex items-center mt-2">
+			<label class="label flex-grow" for="typingTest">{$t('studies.hasToLoggin')}*</label>
+			<input
+				type="checkbox"
+				class="checkbox checkbox-primary size-8"
+				id="typingTest"
+				bind:checked={hasToLoggin}
+			/>
+		</div>
+
+		<!-- Users Section -->
+		<label class="label" for="users">{$t('utils.words.users')}</label>
+		<table class="table">
+			<thead>
+				<tr>
+					<td>#</td>
+					<td>{$t('users.category')}</td>
+					<td>{$t('users.nickname')}</td>
+					<td>{$t('users.email')}</td>
+					<td></td>
+				</tr>
+			</thead>
+			<tbody>
+				{#each users ?? [] as user (user.id)}
+					<tr>
+						<td>{user.id}</td>
+						<td>{$t('users.type.' + user.type)}</td>
+						<td>{user.nickname}</td>
+						<td>{user.email}</td>
+						<td>
+							<button
+								type="button"
+								class="btn btn-sm btn-error text-white"
+								onclick={() => removeUser(user)}
+							>
+								{$t('button.remove')}
+							</button>
+						</td>
+					</tr>
+				{/each}
+			</tbody>
+		</table>
+		<button type="button" class="btn btn-primary block mx-auto mt-3" onclick={addUser}>
+			{$t('studies.addUserButton')}
+		</button>
+
+		<!-- Consent Section -->
+		<h3 class="py-2 px-1">{$t('register.consent.title')}</h3>
+		<label class="label text-sm" for="consentParticipation"
+			>{$t('register.consent.participation')}</label
+		>
+		<textarea
+			use:autosize
+			class="input w-full max-h-52"
+			id="consentParticipation"
+			name="consentParticipation"
+			bind:value={study.consentParticipation}
+		></textarea>
+		<label class="label text-sm" for="consentPrivacy">{$t('register.consent.privacy')}</label>
+		<textarea
+			use:autosize
+			class="input w-full max-h-52"
+			id="consentPrivacy"
+			name="consentPrivacy"
+			bind:value={study.consentPrivacy}
+		></textarea>
+		<label class="label text-sm" for="consentRights">{$t('register.consent.rights')}</label>
+		<textarea
+			use:autosize
+			class="input w-full max-h-52"
+			id="consentRights"
+			name="consentRights"
+			bind:value={study.consentRights}
+		></textarea>
+		<label class="label text-sm" for="consentStudyData"
+			>{$t('register.consent.studyData.title')}</label
+		>
+		<textarea
+			use:autosize
+			class="input w-full max-h-52"
+			id="consentStudyData"
+			name="consentStudyData"
+			bind:value={study.consentStudyData}
+		></textarea>
+
+		{#if mode === 'create'}
+			<div class="mt-4 mb-6">
+				<button class="button">{$t('button.create')}</button>
+				<a class="btn btn-outline float-end ml-2" href="/admin/studies">
+					{$t('button.cancel')}
+				</a>
+			</div>
+		{:else}
+			<div class="mt-4 mb-6">
+				<button class="button" onclick={studyUpdate}>{$t('button.update')}</button>
+				<a class="btn btn-outline float-end ml-2" href="/admin/studies">
+					{$t('button.cancel')}
+				</a>
+				<button
+					class="btn btn-error btn-outline float-end"
+					onclick={() => confirm($t('studies.deleteConfirm')) && deleteStudy()}
+				>
+					{$t('button.delete')}
+				</button>
+			</div>
+		{/if}
+	</form>
+</div>
+<dialog class="modal bg-black bg-opacity-50" open={newUserModal}>
+	<div class="modal-box">
+		<h2 class="text-xl font-bold mb-4">{$t('studies.newUser')}</h2>
+		<div class="w-full flex">
+			<input
+				type="text"
+				placeholder={$t('utils.words.email')}
+				bind:value={newUsername}
+				class="input flex-grow mr-2"
+				onkeypress={(e) => e.key === 'Enter' && searchUser()}
+			/>
+			<button class="button w-16" onclick={searchUser}>
+				<Icon src={MagnifyingGlass} />
+			</button>
+		</div>
+		<button class="btn float-end mt-4" onclick={() => (newUserModal = false)}>Close</button>
+	</div>
+</dialog>
diff --git a/frontend/src/lib/types/study.ts b/frontend/src/lib/types/study.svelte.ts
similarity index 53%
rename from frontend/src/lib/types/study.ts
rename to frontend/src/lib/types/study.svelte.ts
index 820e3aa7..1e9e67cf 100644
--- a/frontend/src/lib/types/study.ts
+++ b/frontend/src/lib/types/study.svelte.ts
@@ -9,15 +9,22 @@ import { parseToLocalDate } from '$lib/utils/date';
 import { toastAlert } from '$lib/utils/toasts';
 import type { fetchType } from '$lib/utils/types';
 import User from './user';
+import SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
+import Survey from '$lib/types/survey';
 
 export default class Study {
 	private _id: number;
-	private _title: string;
-	private _description: string;
+	private _title: string = $state('');
+	private _description: string = $state('');
 	private _startDate: Date;
 	private _endDate: Date;
-	private _chatDuration: number;
+	private _chatDuration: number = $state(0);
 	private _users: User[];
+	private _consentParticipation: string = $state('');
+	private _consentPrivacy: string = $state('');
+	private _consentRights: string = $state('');
+	private _consentStudyData: string = $state('');
+	private _tests: (SurveyTypingSvelte | Survey)[] = $state([]);
 
 	private constructor(
 		id: number,
@@ -26,7 +33,12 @@ export default class Study {
 		startDate: Date,
 		endDate: Date,
 		chatDuration: number,
-		users: User[]
+		users: User[],
+		consentParticipation: string,
+		consentPrivacy: string,
+		consentRights: string,
+		consentStudyData: string,
+		tests: (SurveyTypingSvelte | Survey)[]
 	) {
 		this._id = id;
 		this._title = title;
@@ -35,6 +47,11 @@ export default class Study {
 		this._endDate = endDate;
 		this._chatDuration = chatDuration;
 		this._users = users;
+		this._consentParticipation = consentParticipation;
+		this._consentPrivacy = consentPrivacy;
+		this._consentRights = consentRights;
+		this._consentStudyData = consentStudyData;
+		this._tests = tests;
 	}
 
 	get id(): number {
@@ -45,42 +62,124 @@ export default class Study {
 		return this._title;
 	}
 
+	set title(value: string) {
+		this._title = value;
+	}
+
 	get description(): string {
 		return this._description;
 	}
 
+	set description(value: string) {
+		this._description = value;
+	}
+
 	get startDate(): Date {
 		return this._startDate;
 	}
 
+	set startDate(value: Date) {
+		this._startDate = value;
+	}
+
 	get endDate(): Date {
 		return this._endDate;
 	}
 
+	set endDate(value: Date) {
+		this._endDate = value;
+	}
+
 	get chatDuration(): number {
 		return this._chatDuration;
 	}
 
+	set chatDuration(value: number) {
+		this._chatDuration = value;
+	}
+
 	get users(): User[] {
 		return this._users;
 	}
 
+	set users(value: User[]) {
+		this._users = value;
+	}
+
 	get numberOfUsers(): number {
 		return this._users.length;
 	}
 
+	get consentParticipation(): string {
+		return this._consentParticipation;
+	}
+
+	set consentParticipation(value: string) {
+		this._consentParticipation = value;
+	}
+
+	get consentPrivacy(): string {
+		return this._consentPrivacy;
+	}
+
+	set consentPrivacy(value: string) {
+		this._consentPrivacy = value;
+	}
+
+	get consentRights(): string {
+		return this._consentRights;
+	}
+
+	set consentRights(value: string) {
+		this._consentRights = value;
+	}
+
+	get consentStudyData(): string {
+		return this._consentStudyData;
+	}
+
+	set consentStudyData(value: string) {
+		this._consentStudyData = value;
+	}
+
+	get tests(): (SurveyTypingSvelte | Survey)[] {
+		return this._tests;
+	}
+
+	set tests(value: (SurveyTypingSvelte | Survey)[]) {
+		this._tests = value;
+	}
+
 	static async create(
 		title: string,
 		description: string,
 		startDate: Date,
 		endDate: Date,
 		chatDuration: number,
+		consentParticipation: string,
+		consentPrivacy: string,
+		consentRights: string,
+		consentStudyData: string,
+		tests: (SurveyTypingSvelte | Survey)[],
 		f: fetchType = fetch
 	): Promise<Study | null> {
 		const id = await createStudyAPI(f, title, description, startDate, endDate, chatDuration, []);
 
 		if (id) {
-			return new Study(id, title, description, startDate, endDate, chatDuration, []);
+			return new Study(
+				id,
+				title,
+				description,
+				startDate,
+				endDate,
+				chatDuration,
+				[],
+				consentParticipation,
+				consentPrivacy,
+				consentRights,
+				consentStudyData,
+				tests
+			);
 		}
 		return null;
 	}
@@ -97,6 +196,11 @@ export default class Study {
 			if (data.start_date) this._startDate = parseToLocalDate(data.start_date);
 			if (data.end_date) this._endDate = parseToLocalDate(data.end_date);
 			if (data.chat_duration) this._chatDuration = data.chat_duration;
+			if (data.consent_participation) this._consentParticipation = data.consent_participation;
+			if (data.consent_privacy) this._consentPrivacy = data.consent_privacy;
+			if (data.consent_rights) this._consentRights = data.consent_rights;
+			if (data.consent_study_data) this._consentStudyData = data.consent_study_data;
+			if (data.tests) this._tests = data.tests;
 			return true;
 		}
 		return false;
@@ -133,7 +237,12 @@ export default class Study {
 			parseToLocalDate(json.start_date),
 			parseToLocalDate(json.end_date),
 			json.chat_duration,
-			[]
+			[],
+			json.consent_participation,
+			json.consent_privacy,
+			json.consent_rights,
+			json.consent_study_data,
+			json.tests || []
 		);
 
 		study._users = User.parseAll(json.users);
diff --git a/frontend/src/routes/admin/studies/+page.svelte b/frontend/src/routes/admin/studies/+page.svelte
index 8ccffe45..f8701c3a 100644
--- a/frontend/src/routes/admin/studies/+page.svelte
+++ b/frontend/src/routes/admin/studies/+page.svelte
@@ -1,6 +1,6 @@
 <script lang="ts">
 	import { t } from '$lib/services/i18n';
-	import Study from '$lib/types/study';
+	import Study from '$lib/types/study.svelte';
 	import { displayDate, formatToUTCDate } from '$lib/utils/date';
 	import autosize from 'svelte-autosize';
 	import DateInput from '$lib/components/utils/dateInput.svelte';
@@ -151,7 +151,10 @@
 	</thead>
 	<tbody>
 		{#each studies as study (study.id)}
-			<tr class="hover:bg-gray-100 hover:cursor-pointer" onclick={() => selectStudy(study)}>
+			<tr
+				class="hover:bg-gray-100 hover:cursor-pointer"
+				onclick={() => (window.location.href = `/admin/studies/${study.id}`)}
+			>
 				<td>{study.id}</td>
 				<td>{displayDate(study.startDate)} - {displayDate(study.endDate)}</td>
 				<td>{study.title}</td>
diff --git a/frontend/src/routes/admin/studies/+page.ts b/frontend/src/routes/admin/studies/+page.ts
index f4043711..27b821e8 100644
--- a/frontend/src/routes/admin/studies/+page.ts
+++ b/frontend/src/routes/admin/studies/+page.ts
@@ -1,5 +1,5 @@
 import { getStudiesAPI } from '$lib/api/studies';
-import Study from '$lib/types/study';
+import Study from '$lib/types/study.svelte';
 import { type Load } from '@sveltejs/kit';
 
 export const load: Load = async ({ fetch }) => {
diff --git a/frontend/src/routes/admin/studies/[id]/+page.svelte b/frontend/src/routes/admin/studies/[id]/+page.svelte
new file mode 100644
index 00000000..fca64a47
--- /dev/null
+++ b/frontend/src/routes/admin/studies/[id]/+page.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+	import StudyForm from '$lib/components/studies/StudyForm.svelte';
+	import SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
+	import type { PageData } from './$types';
+
+	let {
+		data
+	}: {
+		data: PageData;
+	} = $props();
+
+	let study = data.study;
+	let typing = $state(new SurveyTypingSvelte());
+
+	let possibleTests = [typing, ...data.surveys];
+
+	let mode = 'edit';
+</script>
+
+{#if study}
+	<StudyForm {study} {possibleTests} {mode} />
+{:else}
+	<p>StudySvelte not found.</p>
+{/if}
diff --git a/frontend/src/routes/admin/studies/[id]/+page.ts b/frontend/src/routes/admin/studies/[id]/+page.ts
new file mode 100644
index 00000000..85b3b8c4
--- /dev/null
+++ b/frontend/src/routes/admin/studies/[id]/+page.ts
@@ -0,0 +1,22 @@
+import { getSurveysAPI } from '$lib/api/survey';
+import Survey from '$lib/types/survey';
+import { type Load, redirect } from '@sveltejs/kit';
+import Study from '$lib/types/study.svelte';
+import { getStudyAPI } from '$lib/api/studies';
+
+export const load: Load = async ({ fetch, params }) => {
+	const surveys = Survey.parseAll(await getSurveysAPI(fetch));
+
+	const id = Number(params.id);
+
+	const study = Study.parse(await getStudyAPI(fetch, id));
+
+	if (!study) {
+		redirect(303, '/admin/studies');
+	}
+
+	return {
+		surveys,
+		study
+	};
+};
diff --git a/frontend/src/routes/admin/studies/new/+page.server.ts b/frontend/src/routes/admin/studies/new/+page.server.ts
index c597172f..d02722fc 100644
--- a/frontend/src/routes/admin/studies/new/+page.server.ts
+++ b/frontend/src/routes/admin/studies/new/+page.server.ts
@@ -11,6 +11,11 @@ export const actions: Actions = {
 		const endDateStr = formData.get('endDate')?.toString();
 		const chatDurationStr = formData.get('chatDuration')?.toString();
 
+		const consentParticipation = formData.get('consentParticipation')?.toString();
+		const consentPrivacy = formData.get('consentPrivacy')?.toString();
+		const consentRights = formData.get('consentRights')?.toString();
+		const consentStudyData = formData.get('consentStudyData')?.toString();
+
 		console.log(title, description, startDateStr, endDateStr, chatDurationStr);
 
 		if (!title || !startDateStr || !endDateStr || !chatDurationStr) {
diff --git a/frontend/src/routes/register/[[studyId]]/+page.svelte b/frontend/src/routes/register/[[studyId]]/+page.svelte
index 85ae010c..6f8f6650 100644
--- a/frontend/src/routes/register/[[studyId]]/+page.svelte
+++ b/frontend/src/routes/register/[[studyId]]/+page.svelte
@@ -7,8 +7,8 @@
 	import Typingtest from '$lib/components/tests/typingtest.svelte';
 	import { browser } from '$app/environment';
 	import type { PageData } from './$types';
-	import type Study from '$lib/types/study';
 	import Consent from '$lib/components/surveys/consent.svelte';
+	import type Study from '$lib/types/study.svelte';
 
 	let { data, form }: { data: PageData; form: FormData } = $props();
 	let study: Study | undefined = $state(data.study);
diff --git a/frontend/src/routes/register/[[studyId]]/+page.ts b/frontend/src/routes/register/[[studyId]]/+page.ts
index 9f2b5121..f0aa77df 100644
--- a/frontend/src/routes/register/[[studyId]]/+page.ts
+++ b/frontend/src/routes/register/[[studyId]]/+page.ts
@@ -1,6 +1,6 @@
 import { getStudiesAPI, getStudyAPI } from '$lib/api/studies';
 import { getUsersAPI } from '$lib/api/users';
-import Study from '$lib/types/study';
+import Study from '$lib/types/study.svelte';
 import type { Load } from '@sveltejs/kit';
 
 export const load: Load = async ({ parent, fetch, params }) => {
-- 
GitLab


From 07cafa21747af607d5898635f5fdb08aad8bdbe1 Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Wed, 19 Feb 2025 15:33:24 +0100
Subject: [PATCH 10/44] reus of same page for creation and modification of
 studies

---
 .../components/studies}/Draggable.svelte      |   0
 .../lib/components/studies/StudyForm.svelte   |  89 ++++---
 .../src/routes/admin/studies/+page.svelte     | 226 +-----------------
 .../src/routes/admin/studies/new/+page.svelte | 205 +---------------
 4 files changed, 49 insertions(+), 471 deletions(-)
 rename frontend/src/{routes/admin/studies/new => lib/components/studies}/Draggable.svelte (100%)

diff --git a/frontend/src/routes/admin/studies/new/Draggable.svelte b/frontend/src/lib/components/studies/Draggable.svelte
similarity index 100%
rename from frontend/src/routes/admin/studies/new/Draggable.svelte
rename to frontend/src/lib/components/studies/Draggable.svelte
diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte
index d81beacd..8f9d797f 100644
--- a/frontend/src/lib/components/studies/StudyForm.svelte
+++ b/frontend/src/lib/components/studies/StudyForm.svelte
@@ -17,18 +17,31 @@
 		possibleTests,
 		mode
 	}: {
-		study: Study;
+		study: Study | null;
 		possibleTests: (Survey | SurveyTypingSvelte)[];
 		mode: string; //"create" or "edit"
 	} = $props();
 
-	let selectedTest: SurveyTypingSvelte | Survey | undefined = $state();
-
 	let hasToLoggin: boolean = $state(false);
 
+	let title: string | null = $state(study?.title ?? null);
+	let description: string | null = $state(study?.description ?? null);
+	let startDate: Date = $state(study?.startDate ?? new Date());
+	let endDate: Date = $state(study?.endDate ?? new Date());
+	let chatDuration: number = $state(study?.chatDuration ?? 30);
+	let tests: (SurveyTypingSvelte | Survey)[] = $state(study?.tests ?? []);
+	let users: User[] = $state(study?.users ?? []);
+	let consentParticipation: string = $state(study?.consentParticipation ?? '');
+	let consentPrivacy: string = $state(study?.consentPrivacy ?? '');
+	let consentRights: string = $state(study?.consentRights ?? '');
+	let consentStudyData: string = $state(study?.consentStudyData ?? '');
 	let newUsername: string = $state('');
 	let newUserModal = $state(false);
-	let users: User[] = $state(study.users);
+	let selectedTest: SurveyTypingSvelte | Survey | undefined = $state();
+
+	console.log(endDate);
+
+	$inspect(endDate);
 
 	async function addUser() {
 		newUserModal = true;
@@ -61,16 +74,16 @@
 	async function studyUpdate() {
 		if (!study) return;
 		const result = await study.patch({
-			title: study.title,
-			description: study.description,
-			start_date: formatToUTCDate(study.startDate),
-			end_date: formatToUTCDate(study.endDate),
-			chat_duration: study.chatDuration,
-			tests: study.tests,
-			consent_participation: study.consentParticipation,
-			consent_privacy: study.consentPrivacy,
-			consent_rights: study.consentRights,
-			consent_study_data: study.consentStudyData
+			title: title,
+			description: description,
+			start_date: formatToUTCDate(startDate),
+			end_date: formatToUTCDate(endDate),
+			chat_duration: chatDuration,
+			tests: tests,
+			consent_participation: consentParticipation,
+			consent_privacy: consentPrivacy,
+			consent_rights: consentRights,
+			consent_study_data: consentStudyData
 		});
 
 		if (result) {
@@ -93,39 +106,23 @@
 		{$t(mode === 'create' ? 'studies.createTitle' : 'studies.editTitle')}
 	</h2>
 	<form method="post">
+		<!-- Title & description -->
 		<label class="label" for="title">{$t('utils.words.title')} *</label>
-		<input
-			class="input w-full"
-			type="text"
-			id="title"
-			name="title"
-			required
-			bind:value={study.title}
-		/>
+		<input class="input w-full" type="text" id="title" name="title" required bind:value={title} />
 		<label class="label" for="description">{$t('utils.words.description')}</label>
 		<textarea
 			use:autosize
 			class="input w-full max-h-52"
 			id="description"
 			name="description"
-			bind:value={study.description}
+			bind:value={description}
 		></textarea>
+
+		<!-- Dates -->
 		<label class="label" for="startDate">{$t('studies.startDate')} *</label>
-		<DateInput
-			class="input w-full"
-			id="startDate"
-			name="startDate"
-			date={study.startDate}
-			required
-		/>
+		<DateInput class="input w-full" id="startDate" name="startDate" date={startDate} required />
 		<label class="label" for="endDate">{$t('studies.endDate')} *</label>
-		<DateInput
-			class="input w-full"
-			id="endDate"
-			name="endDate"
-			date={study.endDate || new Date()}
-			required
-		/>
+		<DateInput class="input w-full" id="endDate" name="endDate" date={endDate} required />
 
 		<!-- Chat Duration -->
 		<label class="label" for="chatDuration">{$t('studies.chatDuration')} *</label>
@@ -135,13 +132,13 @@
 			id="chatDuration"
 			name="chatDuration"
 			min="0"
-			bind:value={study.chatDuration}
+			bind:value={chatDuration}
 			required
 		/>
 
 		<!-- Tests Section -->
 		<h3 class="py-2 px-1">{$t('Tests')}</h3>
-		<Draggable bind:items={study.tests} name="tests" />
+		<Draggable bind:items={tests} name="tests" />
 		<div class="flex">
 			<select class="select select-bordered flex-grow" bind:value={selectedTest}>
 				{#each possibleTests as test}
@@ -157,7 +154,7 @@
 				onclick={(e) => {
 					e.preventDefault();
 					if (selectedTest === undefined) return;
-					study.tests = [...study.tests, selectedTest];
+					tests = [...tests, selectedTest];
 				}}
 			>
 				+
@@ -180,7 +177,6 @@
 		<table class="table">
 			<thead>
 				<tr>
-					<td>#</td>
 					<td>{$t('users.category')}</td>
 					<td>{$t('users.nickname')}</td>
 					<td>{$t('users.email')}</td>
@@ -190,7 +186,6 @@
 			<tbody>
 				{#each users ?? [] as user (user.id)}
 					<tr>
-						<td>{user.id}</td>
 						<td>{$t('users.type.' + user.type)}</td>
 						<td>{user.nickname}</td>
 						<td>{user.email}</td>
@@ -221,7 +216,7 @@
 			class="input w-full max-h-52"
 			id="consentParticipation"
 			name="consentParticipation"
-			bind:value={study.consentParticipation}
+			bind:value={consentParticipation}
 		></textarea>
 		<label class="label text-sm" for="consentPrivacy">{$t('register.consent.privacy')}</label>
 		<textarea
@@ -229,7 +224,7 @@
 			class="input w-full max-h-52"
 			id="consentPrivacy"
 			name="consentPrivacy"
-			bind:value={study.consentPrivacy}
+			bind:value={consentPrivacy}
 		></textarea>
 		<label class="label text-sm" for="consentRights">{$t('register.consent.rights')}</label>
 		<textarea
@@ -237,7 +232,7 @@
 			class="input w-full max-h-52"
 			id="consentRights"
 			name="consentRights"
-			bind:value={study.consentRights}
+			bind:value={consentRights}
 		></textarea>
 		<label class="label text-sm" for="consentStudyData"
 			>{$t('register.consent.studyData.title')}</label
@@ -247,7 +242,7 @@
 			class="input w-full max-h-52"
 			id="consentStudyData"
 			name="consentStudyData"
-			bind:value={study.consentStudyData}
+			bind:value={consentStudyData}
 		></textarea>
 
 		{#if mode === 'create'}
@@ -259,7 +254,7 @@
 			</div>
 		{:else}
 			<div class="mt-4 mb-6">
-				<button class="button" onclick={studyUpdate}>{$t('button.update')}</button>
+				<button type="button" class="button" onclick={studyUpdate}>{$t('button.update')}</button>
 				<a class="btn btn-outline float-end ml-2" href="/admin/studies">
 					{$t('button.cancel')}
 				</a>
diff --git a/frontend/src/routes/admin/studies/+page.svelte b/frontend/src/routes/admin/studies/+page.svelte
index f8701c3a..b8decf0e 100644
--- a/frontend/src/routes/admin/studies/+page.svelte
+++ b/frontend/src/routes/admin/studies/+page.svelte
@@ -1,141 +1,12 @@
 <script lang="ts">
 	import { t } from '$lib/services/i18n';
 	import Study from '$lib/types/study.svelte';
-	import { displayDate, formatToUTCDate } from '$lib/utils/date';
-	import autosize from 'svelte-autosize';
-	import DateInput from '$lib/components/utils/dateInput.svelte';
-	import { toastAlert, toastSuccess, toastWarning } from '$lib/utils/toasts';
-	import User from '$lib/types/user';
-	import { Icon, MagnifyingGlass } from 'svelte-hero-icons';
-	import { getUserByEmailAPI } from '$lib/api/users';
+	import { displayDate } from '$lib/utils/date';
 	import type { PageData } from './$types';
 
 	const { data }: { data: PageData } = $props();
 
 	let studies: Study[] = $state(data.studies);
-	let selectedStudy: Study | null = $state(null);
-	let title: string | null = $state(null);
-	let description: string | null = $state(null);
-	let startDate: Date | null = $state(null);
-	let endDate: Date | null = $state(null);
-	let chatDuration: number = $state(30);
-	let typingTest: boolean = $state(false);
-
-	function selectStudy(study: Study | null) {
-		selectedStudy = study;
-
-		title = study?.title ?? null;
-		description = study?.description ?? null;
-		startDate = study?.startDate ?? null;
-		endDate = study?.endDate ?? null;
-		chatDuration = study?.chatDuration ?? 30;
-	}
-
-	async function studyUpdate() {
-		if (
-			selectedStudy === null ||
-			title === null ||
-			description === null ||
-			startDate === null ||
-			endDate === null ||
-			chatDuration === null ||
-			title === '' ||
-			description === '' ||
-			(title === selectedStudy.title &&
-				description === selectedStudy.description &&
-				startDate.getDay() === selectedStudy.startDate.getDay() &&
-				startDate.getMonth() === selectedStudy.startDate.getMonth() &&
-				startDate.getFullYear() === selectedStudy.startDate.getFullYear() &&
-				endDate.getDay() === selectedStudy.endDate.getDay() &&
-				endDate.getMonth() === selectedStudy.endDate.getMonth() &&
-				endDate.getFullYear() === selectedStudy.endDate.getFullYear() &&
-				chatDuration === selectedStudy.chatDuration)
-		) {
-			selectStudy(null);
-			toastSuccess($t('studies.noChanges'));
-			return;
-		}
-
-		const result = await selectedStudy.patch({
-			title,
-			description,
-			start_date: formatToUTCDate(startDate),
-			end_date: formatToUTCDate(endDate),
-			chat_duration: chatDuration,
-			typing_test: typingTest
-		});
-
-		if (result) {
-			selectStudy(null);
-			toastSuccess($t('studies.updated'));
-		} else {
-			toastAlert($t('studies.updateError'));
-		}
-	}
-
-	async function deleteStudy() {
-		if (!selectedStudy) return;
-
-		studies.splice(studies.indexOf(selectedStudy), 1);
-		selectedStudy?.delete();
-		selectStudy(null);
-	}
-
-	async function removeUser(user: User) {
-		if (selectedStudy === null) return;
-		if (!confirm($t('studies.removeUserConfirm'))) return;
-
-		const res = await selectedStudy.removeUser(user);
-
-		if (res) {
-			toastSuccess($t('studies.removeUserSuccess'));
-			selectStudy(null);
-		} else {
-			toastAlert($t('studies.removeUserError'));
-		}
-	}
-
-	let newUsername: string = $state('');
-	let newUserModal = $state(false);
-
-	async function addUser() {
-		if (selectedStudy === null) return;
-		newUserModal = true;
-	}
-
-	async function searchUser() {
-		if (selectedStudy === null) return;
-		if (!newUsername || !newUsername.includes('@')) {
-			toastWarning($t('studies.invalidEmail'));
-			return;
-		}
-
-		const userData = await getUserByEmailAPI(fetch, newUsername);
-
-		if (!userData) {
-			toastWarning($t('studies.userNotFound'));
-			return;
-		}
-
-		const user = User.parse(userData);
-
-		if (!user) {
-			toastAlert($t('studies.userNotFound'));
-			return;
-		}
-
-		const res = await selectedStudy.addUser(user);
-
-		if (!res) {
-			toastAlert($t('studies.addUserError'));
-			return;
-		}
-
-		newUsername = '';
-		newUserModal = false;
-		toastSuccess($t('studies.addUserSuccess'));
-		selectStudy(null);
-	}
 </script>
 
 <h1 class="text-xl font-bold m-5 text-center">{$t('header.admin.studies')}</h1>
@@ -167,98 +38,3 @@
 <div class="mt-8 w-[64rem] mx-auto">
 	<a class="button" href="/admin/studies/new">{$t('studies.create')}</a>
 </div>
-
-<dialog class="modal bg-black bg-opacity-50" open={selectedStudy != null}>
-	<div class="modal-box max-w-4xl">
-		<h2 class="text-xl font-bold m-5 text-center">{$t('studies.study')} #{selectedStudy?.id}</h2>
-		<form class="mb-4">
-			<label class="label" for="title">{$t('utils.words.title')}</label>
-			<input class="input w-full" type="text" id="title" bind:value={title} />
-			<label class="label" for="description">{$t('utils.words.description')}</label>
-			<textarea use:autosize class="input w-full max-h-52" id="title" bind:value={description}>
-			</textarea>
-			<label class="label" for="startDate">{$t('studies.startDate')}</label>
-			<DateInput class="input w-full" id="startDate" date={startDate} />
-			<label class="label" for="endDate">{$t('studies.endDate')}</label>
-			<DateInput class="input w-full" id="endDate" date={endDate} />
-			<label class="label" for="chatDuration">{$t('studies.chatDuration')}</label>
-			<input
-				class="input w-full"
-				type="number"
-				id="chatDuration"
-				bind:value={chatDuration}
-				min="0"
-			/>
-			<div class="flex items-center mt-2">
-				<label class="label flex-grow" for="typingTest">{$t('studies.typingTest')} *</label>
-				<input
-					type="checkbox"
-					class="checkbox checkbox-primary size-8"
-					id="typingTest"
-					bind:checked={typingTest}
-				/>
-			</div>
-			<label class="label" for="users">{$t('utils.words.users')}</label>
-			<table class="table">
-				<thead>
-					<tr>
-						<td>#</td>
-						<td>{$t('users.category')}</td>
-						<td>{$t('users.nickname')}</td>
-						<td>{$t('users.email')}</td>
-						<td></td>
-					</tr>
-				</thead>
-				<tbody>
-					{#each selectedStudy?.users ?? [] as user (user.id)}
-						<tr>
-							<td>{user.id}</td>
-							<td>{$t('users.type.' + user.type)}</td>
-							<td>{user.nickname}</td>
-							<td>{user.email}</td>
-							<td>
-								<button class="btn btn-sm btn-error text-white" onclick={() => removeUser(user)}>
-									{$t('button.remove')}
-								</button>
-							</td>
-						</tr>
-					{/each}
-				</tbody>
-			</table>
-			<button class="btn btn-primary block mx-auto" onclick={addUser}>
-				{$t('studies.addUserButton')}
-			</button>
-		</form>
-		<div class="mt-4">
-			<button class="button" onclick={studyUpdate}>{$t('button.update')}</button>
-			<button class="btn btn-outline float-end ml-2" onclick={() => selectStudy(null)}>
-				{$t('button.cancel')}
-			</button>
-			<button
-				class="btn btn-error btn-outline float-end"
-				onclick={() => confirm($t('studies.deleteConfirm')) && deleteStudy()}
-			>
-				{$t('button.delete')}
-			</button>
-		</div>
-	</div>
-</dialog>
-
-<dialog class="modal bg-black bg-opacity-50" open={newUserModal}>
-	<div class="modal-box">
-		<h2 class="text-xl font-bold mb-4">{$t('studies.newUser')}</h2>
-		<div class="w-full flex">
-			<input
-				type="text"
-				placeholder={$t('utils.words.email')}
-				bind:value={newUsername}
-				class="input flex-grow mr-2"
-				onkeypress={(e) => e.key === 'Enter' && searchUser()}
-			/>
-			<button class="button w-16" onclick={searchUser}>
-				<Icon src={MagnifyingGlass} />
-			</button>
-		</div>
-		<button class="btn float-end mt-4" onclick={() => (newUserModal = false)}>Close</button>
-	</div>
-</dialog>
diff --git a/frontend/src/routes/admin/studies/new/+page.svelte b/frontend/src/routes/admin/studies/new/+page.svelte
index 75aebead..a28ea035 100644
--- a/frontend/src/routes/admin/studies/new/+page.svelte
+++ b/frontend/src/routes/admin/studies/new/+page.svelte
@@ -1,206 +1,13 @@
 <script lang="ts">
-	import Survey from '$lib/types/survey';
+	import StudyForm from '$lib/components/studies/StudyForm.svelte';
 	import SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
-	import type { PageData, ActionData } from './$types';
-	import Draggable from './Draggable.svelte';
-	import autosize from 'svelte-autosize';
-	import { t } from '$lib/services/i18n';
-	import DateInput from '$lib/components/utils/dateInput.svelte';
-	import { toastAlert, toastWarning } from '$lib/utils/toasts';
-	import { getUserByEmailAPI } from '$lib/api/users';
-	import User from '$lib/types/user';
-	import { Icon, MagnifyingGlass } from 'svelte-hero-icons';
-
-	let { data, form }: { data: PageData; form: ActionData } = $props();
-
-	let tests: (SurveyTypingSvelte | Survey)[] = $state([]);
+	import type { PageData } from './$types';
 
+	let { data }: { data: PageData } = $props();
+	let study = null;
 	let typing = $state(new SurveyTypingSvelte());
-
 	let possibleTests = [typing, ...data.surveys];
-	let selectedTest: SurveyTypingSvelte | Survey | undefined = $state();
-
-	let hasToLoggin: boolean = $state(false);
-
-	let newUsername: string = $state('');
-	let newUserModal = $state(false);
-	let users: User[] = $state([]);
-
-	async function addUser() {
-		newUserModal = true;
-	}
-
-	async function searchUser() {
-		if (!newUsername || !newUsername.includes('@')) {
-			toastWarning($t('studies.invalidEmail'));
-			return;
-		}
-		const userData = await getUserByEmailAPI(fetch, newUsername);
-		if (!userData) {
-			toastWarning($t('studies.userNotFound'));
-			return;
-		}
-		const user = User.parse(userData);
-		if (!user) {
-			toastAlert($t('studies.userNotFound'));
-			return;
-		}
-		users = [...users, user];
-		newUsername = '';
-		newUserModal = false;
-	}
-
-	async function removeUser(user: User) {
-		users = users.filter((u) => u.id !== user.id);
-	}
+	let mode = 'create';
 </script>
 
-<div class="mx-auto w-full max-w-5xl px-4">
-	<h2 class="text-xl font-bold m-5 text-center">{$t('studies.createTitle')}</h2>
-	{#if form?.message}
-		<div class="alert alert-error mb-4">
-			{form.message}
-		</div>
-	{/if}
-	<form method="post">
-		<label class="label" for="title">{$t('utils.words.title')} *</label>
-		<input class="input w-full" type="text" id="title" name="title" required />
-		<label class="label" for="description">{$t('utils.words.description')}</label>
-		<textarea use:autosize class="input w-full max-h-52" id="description" name="description"
-		></textarea>
-		<label class="label" for="startDate">{$t('studies.startDate')} *</label>
-		<DateInput class="input w-full" id="startDate" name="startDate" date={new Date()} required />
-		<label class="label" for="endDate">{$t('studies.endDate')} *</label>
-		<DateInput class="input w-full" id="endDate" name="endDate" date={new Date()} required />
-		<label class="label" for="chatDuration">{$t('studies.chatDuration')} *</label>
-		<input
-			class="input w-full"
-			type="number"
-			id="chatDuration"
-			name="chatDuration"
-			min="0"
-			value="30"
-			required
-		/>
-
-		<h3 class="py-2 px-1">{$t('Tests')}</h3>
-		<Draggable bind:items={tests} name="tests" />
-		<div class="flex">
-			<select class="select select-bordered flex-grow" bind:value={selectedTest}>
-				{#each possibleTests as test}
-					{#if test instanceof Survey}
-						<option value={test}>{test.title}</option>
-					{:else}
-						<option value={test}>{test.name}</option>
-					{/if}
-				{/each}
-			</select>
-			<button
-				class="ml-2 button"
-				onclick={(e) => {
-					e.preventDefault();
-					if (selectedTest === undefined) return;
-					tests = [...tests, selectedTest];
-				}}
-			>
-				+
-			</button>
-		</div>
-
-		<div class="flex items-center mt-2">
-			<label class="label flex-grow" for="typingTest">{$t('studies.hasToLoggin')}*</label>
-			<input
-				type="checkbox"
-				class="checkbox checkbox-primary size-8"
-				id="typingTest"
-				bind:checked={hasToLoggin}
-			/>
-		</div>
-
-		<label class="label" for="users">{$t('utils.words.users')}</label>
-		<table class="table">
-			<thead>
-				<tr>
-					<td>#</td>
-					<td>{$t('users.category')}</td>
-					<td>{$t('users.nickname')}</td>
-					<td>{$t('users.email')}</td>
-					<td></td>
-				</tr>
-			</thead>
-			<tbody>
-				{#each users ?? [] as user (user.id)}
-					<tr>
-						<td>{user.id}</td>
-						<td>{$t('users.type.' + user.type)}</td>
-						<td>{user.nickname}</td>
-						<td>{user.email}</td>
-						<td>
-							<button
-								type="button"
-								class="btn btn-sm btn-error text-white"
-								onclick={() => removeUser(user)}
-							>
-								{$t('button.remove')}
-							</button>
-						</td>
-					</tr>
-				{/each}
-			</tbody>
-		</table>
-		<button type="button" class="btn btn-primary block mx-auto mt-3" onclick={addUser}>
-			{$t('studies.addUserButton')}
-		</button>
-
-		<h3 class="py-2 px-1">{$t('register.consent.title')}</h3>
-		<label class="label text-sm" for="consentParticipation"
-			>{$t('register.consent.participation')}</label
-		>
-		<textarea
-			use:autosize
-			class="input w-full max-h-52"
-			id="consentParticipation"
-			name="consentParticipation"
-		></textarea>
-		<label class="label text-sm" for="consentPrivacy">{$t('register.consent.privacy')}</label>
-		<textarea use:autosize class="input w-full max-h-52" id="consentPrivacy" name="consentPrivacy"
-		></textarea>
-		<label class="label text-sm" for="consentRights">{$t('register.consent.rights')}</label>
-		<textarea use:autosize class="input w-full max-h-52" id="consentRights" name="consentRights"
-		></textarea>
-		<label class="label text-sm" for="consentStudyData"
-			>{$t('register.consent.studyData.title')}</label
-		>
-		<textarea
-			use:autosize
-			class="input w-full max-h-52"
-			id="consentStudyData"
-			name="consentStudyData"
-		></textarea>
-
-		<div class="mt-4 mb-6">
-			<button class="button">{$t('button.create')}</button>
-			<a class="btn btn-outline float-end ml-2" href="/admin/studies">
-				{$t('button.cancel')}
-			</a>
-		</div>
-	</form>
-</div>
-<dialog class="modal bg-black bg-opacity-50" open={newUserModal}>
-	<div class="modal-box">
-		<h2 class="text-xl font-bold mb-4">{$t('studies.newUser')}</h2>
-		<div class="w-full flex">
-			<input
-				type="text"
-				placeholder={$t('utils.words.email')}
-				bind:value={newUsername}
-				class="input flex-grow mr-2"
-				onkeypress={(e) => e.key === 'Enter' && searchUser()}
-			/>
-			<button class="button w-16" onclick={searchUser}>
-				<Icon src={MagnifyingGlass} />
-			</button>
-		</div>
-		<button class="btn float-end mt-4" onclick={() => (newUserModal = false)}>Close</button>
-	</div>
-</dialog>
+<StudyForm {study} {possibleTests} {mode} />
-- 
GitLab


From cab3d946dcab03fb6c8ac9ecbd6b92ad05e7f068 Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Wed, 19 Feb 2025 16:09:32 +0100
Subject: [PATCH 11/44] Fixing tiny error message (delete button, not submit
 form)

---
 frontend/src/lib/components/studies/StudyForm.svelte | 1 +
 1 file changed, 1 insertion(+)

diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte
index 8f9d797f..50a6f821 100644
--- a/frontend/src/lib/components/studies/StudyForm.svelte
+++ b/frontend/src/lib/components/studies/StudyForm.svelte
@@ -259,6 +259,7 @@
 					{$t('button.cancel')}
 				</a>
 				<button
+					type="button"
 					class="btn btn-error btn-outline float-end"
 					onclick={() => confirm($t('studies.deleteConfirm')) && deleteStudy()}
 				>
-- 
GitLab


From a18db43f7ca634396b02cdf64ef2b2e024f0731b Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Fri, 21 Feb 2025 10:46:05 +0100
Subject: [PATCH 12/44] consent form field mandatory

---
 frontend/src/lib/components/studies/StudyForm.svelte | 12 ++++++++----
 1 file changed, 8 insertions(+), 4 deletions(-)

diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte
index 50a6f821..4204b0a8 100644
--- a/frontend/src/lib/components/studies/StudyForm.svelte
+++ b/frontend/src/lib/components/studies/StudyForm.svelte
@@ -209,7 +209,7 @@
 		<!-- Consent Section -->
 		<h3 class="py-2 px-1">{$t('register.consent.title')}</h3>
 		<label class="label text-sm" for="consentParticipation"
-			>{$t('register.consent.participation')}</label
+			>{$t('register.consent.participation')} *</label
 		>
 		<textarea
 			use:autosize
@@ -217,25 +217,28 @@
 			id="consentParticipation"
 			name="consentParticipation"
 			bind:value={consentParticipation}
+			required
 		></textarea>
-		<label class="label text-sm" for="consentPrivacy">{$t('register.consent.privacy')}</label>
+		<label class="label text-sm" for="consentPrivacy">{$t('register.consent.privacy')} *</label>
 		<textarea
 			use:autosize
 			class="input w-full max-h-52"
 			id="consentPrivacy"
 			name="consentPrivacy"
 			bind:value={consentPrivacy}
+			required
 		></textarea>
-		<label class="label text-sm" for="consentRights">{$t('register.consent.rights')}</label>
+		<label class="label text-sm" for="consentRights">{$t('register.consent.rights')} *</label>
 		<textarea
 			use:autosize
 			class="input w-full max-h-52"
 			id="consentRights"
 			name="consentRights"
 			bind:value={consentRights}
+			required
 		></textarea>
 		<label class="label text-sm" for="consentStudyData"
-			>{$t('register.consent.studyData.title')}</label
+			>{$t('register.consent.studyData.title')} *</label
 		>
 		<textarea
 			use:autosize
@@ -243,6 +246,7 @@
 			id="consentStudyData"
 			name="consentStudyData"
 			bind:value={consentStudyData}
+			required
 		></textarea>
 
 		{#if mode === 'create'}
-- 
GitLab


From 6da93ed8cfad542edcf1fe3942984bb23b80bdcf Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Fri, 21 Feb 2025 15:29:01 +0100
Subject: [PATCH 13/44] Add fields to request when creating studies

---
 frontend/src/lib/api/studies.ts               | 12 +++++++--
 .../routes/admin/studies/new/+page.server.ts  | 27 ++++++++++++++++---
 2 files changed, 33 insertions(+), 6 deletions(-)

diff --git a/frontend/src/lib/api/studies.ts b/frontend/src/lib/api/studies.ts
index 846f4491..d70b2eab 100644
--- a/frontend/src/lib/api/studies.ts
+++ b/frontend/src/lib/api/studies.ts
@@ -27,7 +27,11 @@ export async function createStudyAPI(
 	startDate: Date,
 	endDate: Date,
 	chatDuration: number,
-	tests: { type: string; id?: number }[]
+	tests: { type: string; id?: number }[],
+	consentParticipation: string,
+	consentPrivacy: string,
+	consentRights: string,
+	consentStudyData: string
 ): Promise<number | null> {
 	const response = await fetch('/api/studies', {
 		method: 'POST',
@@ -38,7 +42,11 @@ export async function createStudyAPI(
 			start_date: formatToUTCDate(startDate),
 			end_date: formatToUTCDate(endDate),
 			chat_duration: chatDuration,
-			tests
+			tests,
+			consent_participation: consentParticipation,
+			consent_privacy: consentPrivacy,
+			consent_rights: consentRights,
+			consent_study_data: consentStudyData
 		})
 	});
 	if (!response.ok) return null;
diff --git a/frontend/src/routes/admin/studies/new/+page.server.ts b/frontend/src/routes/admin/studies/new/+page.server.ts
index d02722fc..d26d02e0 100644
--- a/frontend/src/routes/admin/studies/new/+page.server.ts
+++ b/frontend/src/routes/admin/studies/new/+page.server.ts
@@ -16,9 +16,16 @@ export const actions: Actions = {
 		const consentRights = formData.get('consentRights')?.toString();
 		const consentStudyData = formData.get('consentStudyData')?.toString();
 
-		console.log(title, description, startDateStr, endDateStr, chatDurationStr);
-
-		if (!title || !startDateStr || !endDateStr || !chatDurationStr) {
+		if (
+			!title ||
+			!startDateStr ||
+			!endDateStr ||
+			!chatDurationStr ||
+			!consentParticipation ||
+			!consentPrivacy ||
+			!consentRights ||
+			!consentStudyData
+		) {
 			return {
 				message: 'Invalid request 1'
 			};
@@ -61,7 +68,19 @@ export const actions: Actions = {
 			})
 			.filter((test) => test !== null);
 
-		await createStudyAPI(fetch, title, description, startDate, endDate, chatDuration, tests);
+		await createStudyAPI(
+			fetch,
+			title,
+			description,
+			startDate,
+			endDate,
+			chatDuration,
+			tests,
+			consentParticipation,
+			consentPrivacy,
+			consentRights,
+			consentStudyData
+		);
 
 		return redirect(303, '/admin/studies');
 	}
-- 
GitLab


From a67635eb8d925faacb8a924c377d8e5cf1d2b995 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Fri, 21 Feb 2025 22:00:54 +0100
Subject: [PATCH 14/44] First steps into the new backend logic for surveys and
 tests

---
 backend/app/config.py                         |   1 -
 backend/app/{crud.py => crud/__init__.py}     | 229 +-------
 backend/app/crud/studies.py                   |  33 ++
 backend/app/crud/tests.py                     | 110 ++++
 backend/app/main.py                           | 539 +-----------------
 backend/app/models.py                         | 140 -----
 backend/app/models/__init__.py                | 180 ++++++
 backend/app/models/studies.py                 |  41 ++
 backend/app/models/tests.py                   | 184 ++++++
 backend/app/routes/__init__.py                |   0
 backend/app/routes/decorators.py              |  21 +
 backend/app/routes/tests.py                   | 185 ++++++
 .../app/{schemas.py => schemas/__init__.py}   | 103 +---
 backend/app/schemas/studies.py                |  23 +
 backend/app/schemas/tests.py                  | 122 ++++
 backend/app/schemas/users.py                  |  83 +++
 backend/app/test_main.py                      | 106 ----
 frontend/src/lib/types/study.svelte.ts        |  14 +-
 scripts/surveys/items.csv                     | 244 --------
 scripts/surveys/questions_gapfill.csv         |  62 ++
 scripts/surveys/questions_qcm.csv             | 175 ++++++
 scripts/surveys/survey_maker.py               | 100 ++--
 .../surveys/{surveys.csv => tests_task.csv}   |   0
 23 files changed, 1288 insertions(+), 1407 deletions(-)
 rename backend/app/{crud.py => crud/__init__.py} (53%)
 create mode 100644 backend/app/crud/studies.py
 create mode 100644 backend/app/crud/tests.py
 create mode 100644 backend/app/models/__init__.py
 create mode 100644 backend/app/models/studies.py
 create mode 100644 backend/app/models/tests.py
 create mode 100644 backend/app/routes/__init__.py
 create mode 100644 backend/app/routes/decorators.py
 create mode 100644 backend/app/routes/tests.py
 rename backend/app/{schemas.py => schemas/__init__.py} (67%)
 create mode 100644 backend/app/schemas/studies.py
 create mode 100644 backend/app/schemas/tests.py
 create mode 100644 backend/app/schemas/users.py
 delete mode 100644 backend/app/test_main.py
 delete mode 100644 scripts/surveys/items.csv
 create mode 100644 scripts/surveys/questions_gapfill.csv
 create mode 100644 scripts/surveys/questions_qcm.csv
 rename scripts/surveys/{surveys.csv => tests_task.csv} (100%)

diff --git a/backend/app/config.py b/backend/app/config.py
index b2652240..5d3620a1 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -1,6 +1,5 @@
 import os
 import secrets
-import logging
 
 # Database
 DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///../languagelab.sqlite")
diff --git a/backend/app/crud.py b/backend/app/crud/__init__.py
similarity index 53%
rename from backend/app/crud.py
rename to backend/app/crud/__init__.py
index 3cfb20fd..1a280d17 100644
--- a/backend/app/crud.py
+++ b/backend/app/crud/__init__.py
@@ -7,6 +7,9 @@ import models
 import schemas
 from hashing import Hasher
 
+from crud.tests import *
+from crud.studies import *
+
 
 def get_user(db: Session, user_id: int):
     return db.query(models.User).filter(models.User.id == user_id).first()
@@ -29,7 +32,7 @@ def get_users(db: Session, skip: int = 0):
     return db.query(models.User).offset(skip).all()
 
 
-def create_user(db: Session, user: schemas.UserCreate):
+def create_user(db: Session, user: schemas.UserCreate) -> models.User:
     password = Hasher.get_password_hash(user.password)
     nickname = user.nickname if user.nickname else user.email.split("@")[0]
     db_user = models.User(
@@ -279,227 +282,3 @@ def create_test_typing_entry(
     db.commit()
     db.refresh(db_entry)
     return db_entry
-
-
-def create_survey(db: Session, survey: schemas.SurveyCreate):
-    db_survey = models.SurveySurvey(**survey.dict())
-    db.add(db_survey)
-    db.commit()
-    db.refresh(db_survey)
-    return db_survey
-
-
-def get_survey(db: Session, survey_id: int):
-    return (
-        db.query(models.SurveySurvey)
-        .filter(models.SurveySurvey.id == survey_id)
-        .first()
-    )
-
-
-def get_surveys(db: Session, skip: int = 0):
-    return db.query(models.SurveySurvey).offset(skip).all()
-
-
-def delete_survey(db: Session, survey_id: int):
-    db.query(models.SurveySurvey).filter(models.SurveySurvey.id == survey_id).delete()
-    db.commit()
-
-
-def add_group_to_survey(db: Session, survey_id: int, group: schemas.SurveyGroup):
-    db_survey = (
-        db.query(models.SurveySurvey)
-        .filter(models.SurveySurvey.id == survey_id)
-        .first()
-    )
-    db_survey.groups.append(group)
-    db.commit()
-    db.refresh(db_survey)
-    return db_survey
-
-
-def remove_group_from_survey(db: Session, survey_id: int, group_id: int):
-    survey = (
-        db.query(models.SurveySurvey)
-        .filter(models.SurveySurvey.id == survey_id)
-        .first()
-    )
-
-    survey_group = (
-        db.query(models.SurveyGroup).filter(models.SurveyGroup.id == group_id).first()
-    )
-
-    if survey_group in survey.groups:
-        survey.groups.remove(survey_group)
-        db.commit()
-
-
-def create_survey_group(db: Session, survey_group: schemas.SurveyGroupCreate):
-    db_survey_group = models.SurveyGroup(**survey_group.dict())
-    db.add(db_survey_group)
-    db.commit()
-    db.refresh(db_survey_group)
-    return db_survey_group
-
-
-def get_survey_group(db: Session, survey_group_id: int) -> schemas.SurveyGroup:
-    return (
-        db.query(models.SurveyGroup)
-        .filter(models.SurveyGroup.id == survey_group_id)
-        .first()
-    )
-
-
-def get_survey_groups(db: Session, skip: int = 0):
-    return db.query(models.SurveyGroup).offset(skip).all()
-
-
-def delete_survey_group(db: Session, survey_group_id: int):
-    db.query(models.SurveyGroup).filter(
-        models.SurveyGroup.id == survey_group_id
-    ).delete()
-    db.commit()
-
-
-def add_item_to_survey_group(db: Session, group_id: int, item: models.SurveyQuestion):
-    db_survey_group = (
-        db.query(models.SurveyGroup).filter(models.SurveyGroup.id == group_id).first()
-    )
-    db_survey_group.questions.append(item)
-    db.commit()
-    db.refresh(db_survey_group)
-    return db_survey_group
-
-
-def remove_item_from_survey_group(db: Session, group_id: int, item_id: int):
-    survey_group = (
-        db.query(models.SurveyGroup).filter(models.SurveyGroup.id == group_id).first()
-    )
-
-    survey_question = (
-        db.query(models.SurveyQuestion)
-        .filter(models.SurveyQuestion.id == item_id)
-        .first()
-    )
-
-    if survey_question in survey_group.questions:
-        survey_group.questions.remove(survey_question)
-        db.commit()
-
-
-def create_survey_question(db: Session, survey_question: schemas.SurveyQuestionCreate):
-    db_survey_question = models.SurveyQuestion(**survey_question.dict())
-    db.add(db_survey_question)
-    db.commit()
-    db.refresh(db_survey_question)
-    return db_survey_question
-
-
-def get_survey_question(db: Session, survey_question_id: int):
-    return (
-        db.query(models.SurveyQuestion)
-        .filter(models.SurveyQuestion.id == survey_question_id)
-        .first()
-    )
-
-
-def get_survey_questions(db: Session, skip: int = 0):
-    return db.query(models.SurveyQuestion).offset(skip).all()
-
-
-def delete_survey_question(db: Session, survey_question_id: int):
-    db.query(models.SurveyQuestion).filter(
-        models.SurveyQuestion.id == survey_question_id
-    ).delete()
-    db.commit()
-
-
-def create_survey_response(db: Session, survey_response: schemas.SurveyResponseCreate):
-    db_survey_response = models.SurveyResponse(**survey_response.dict())
-    db.add(db_survey_response)
-    db.commit()
-    db.refresh(db_survey_response)
-    return db_survey_response
-
-
-def get_survey_responses(db: Session, sid: str, skip: int = 0):
-    return (
-        db.query(models.SurveyResponse)
-        .filter(models.SurveyResponse.sid == sid)
-        .offset(skip)
-        .all()
-    )
-
-
-def create_survey_response_info(
-    db: Session, survey_response_info: schemas.SurveyResponseInfoCreate
-):
-    db_survey_response_info = models.SurveyResponseInfo(**survey_response_info.dict())
-    db.add(db_survey_response_info)
-    db.commit()
-    db.refresh(db_survey_response_info)
-    return db_survey_response_info
-
-
-def create_study(db: Session, study: schemas.StudyCreate):
-    db_study = models.Study(**study.dict())
-    db.add(db_study)
-    db.commit()
-    db.refresh(db_study)
-    return db_study
-
-
-def get_study(db: Session, study_id: int):
-    return db.query(models.Study).filter(models.Study.id == study_id).first()
-
-
-def get_studies(db: Session, skip: int = 0):
-    return db.query(models.Study).offset(skip).all()
-
-
-def update_study(db: Session, study: schemas.StudyCreate, study_id: int):
-    db.query(models.Study).filter(models.Study.id == study_id).update(
-        {**study.model_dump(exclude_unset=True)}
-    )
-    db.commit()
-
-
-def delete_study(db: Session, study_id: int):
-    db.query(models.Study).filter(models.Study.id == study_id).delete()
-    db.commit()
-
-
-def add_user_to_study(db: Session, study_id: int, user: schemas.User):
-    db_study = db.query(models.Study).filter(models.Study.id == study_id).first()
-    db_study.users.append(user)
-    db.commit()
-    db.refresh(db_study)
-    return db_study
-
-
-def remove_user_from_study(db: Session, study_id: int, user: schemas.User):
-    study = db.query(models.Study).filter(models.Study.id == study_id).first()
-    user = db.query(models.User).filter(models.User.id == user.id).first()
-    study.users.remove(user)
-    db.commit()
-    return study
-
-
-def add_survey_to_study(db: Session, study_id: int, survey: schemas.Survey):
-    db_study = db.query(models.Study).filter(models.Study.id == study_id).first()
-    db_study.surveys.append(survey)
-    db.commit()
-    db.refresh(db_study)
-    return db_study
-
-
-def remove_survey_from_study(db: Session, study_id: int, survey: schemas.Survey):
-    study = db.query(models.Study).filter(models.Study.id == study_id).first()
-    survey = (
-        db.query(models.SurveySurvey)
-        .filter(models.SurveySurvey.id == survey.id)
-        .first()
-    )
-    study.surveys.remove(survey)
-    db.commit()
-    return study
diff --git a/backend/app/crud/studies.py b/backend/app/crud/studies.py
new file mode 100644
index 00000000..9d73af8d
--- /dev/null
+++ b/backend/app/crud/studies.py
@@ -0,0 +1,33 @@
+from typing import Optional
+from sqlalchemy.orm import Session
+
+import models
+import schemas
+
+
+def create_study(db: Session, study: schemas.StudyCreate) -> models.Study:
+    db_study = models.Study(**study.model_dump())
+    db.add(db_study)
+    db.commit()
+    db.refresh(db_study)
+    return db_study
+
+
+def get_study(db: Session, study_id: int) -> Optional[models.Study]:
+    return db.query(models.Study).filter(models.Study.id == study_id).first()
+
+
+def get_studies(db: Session, skip: int = 0) -> list[models.Study]:
+    return db.query(models.Study).offset(skip).all()
+
+
+def update_study(db: Session, study: schemas.StudyCreate, study_id: int) -> None:
+    db.query(models.Study).filter(models.Study.id == study_id).update(
+        {**study.model_dump(exclude_unset=True)}
+    )
+    db.commit()
+
+
+def delete_study(db: Session, study_id: int) -> None:
+    db.query(models.Study).filter(models.Study.id == study_id).delete()
+    db.commit()
diff --git a/backend/app/crud/tests.py b/backend/app/crud/tests.py
new file mode 100644
index 00000000..e8b5ea20
--- /dev/null
+++ b/backend/app/crud/tests.py
@@ -0,0 +1,110 @@
+from sqlalchemy.orm import Session
+
+import models
+import schemas
+
+
+def create_test(db: Session, test: schemas.TestCreate) -> models.Test:
+    db_test = models.Test(**test.model_dump())
+    db.add(db_test)
+    db.commit()
+    db.refresh(db_test)
+    return db_test
+
+
+def get_tests(db: Session, skip: int = 0) -> list[models.Test]:
+    return db.query(models.Test).offset(skip).all()
+
+
+def get_test(db: Session, test_id: int) -> models.Test:
+    return db.query(models.Test).filter(models.Test.id == test_id).first()
+
+
+def delete_test(db: Session, test_id: int) -> None:
+    db.query(models.Test).filter(models.Test.id == test_id).delete()
+    db.commit()
+
+
+def add_group_to_test_task(db: Session, test: models.Test, group: models.TestTaskGroup):
+    test.groups.append(group)
+    db.commit()
+    db.refresh(test)
+
+
+def remove_group_from_test_task(
+    db: Session, testTask: models.TestTask, group: models.TestTaskGroup
+):
+    testTask.groups.remove(group)
+    db.commit()
+    db.refresh(testTask)
+
+
+def create_group(
+    db: Session, group: schemas.TestTaskGroupCreate
+) -> models.TestTaskGroup:
+    db_group = models.TestTaskGroup(**group.model_dump())
+    db.add(db_group)
+    db.commit()
+    db.refresh(db_group)
+    return db_group
+
+
+def get_group(db: Session, group_id: int) -> models.TestTaskGroup:
+    return (
+        db.query(models.TestTaskGroup)
+        .filter(models.TestTaskGroup.id == group_id)
+        .first()
+    )
+
+
+def delete_group(db: Session, group_id: int) -> None:
+    db.query(models.TestTaskGroup).filter(models.TestTaskGroup.id == group_id).delete()
+    db.commit()
+
+
+def add_question_to_group(
+    db: Session, group: models.TestTaskGroup, question: models.TestTaskQuestion
+):
+    group.questions.append(question)
+    db.commit()
+    db.refresh(group)
+
+
+def remove_question_from_group(
+    db: Session, group: models.TestTaskGroup, question: models.TestTaskQuestion
+):
+    group.questions.remove(question)
+    db.commit()
+    db.refresh(group)
+
+
+def create_question(db: Session, question: schemas.TestTaskQuestionCreate):
+    db_question = models.TestTaskQuestion(**question.model_dump())
+    db.add(db_question)
+    db.commit()
+    db.refresh(db_question)
+    return db_question
+
+
+def get_question(db: Session, question_id: int):
+    return (
+        db.query(models.TestTaskQuestion)
+        .filter(models.TestTaskQuestion.id == question_id)
+        .first()
+    )
+
+
+def delete_question(db: Session, question_id: int):
+    db.query(models.TestTaskQuestion).filter(
+        models.TestTaskQuestion.id == question_id
+    ).delete()
+    db.commit()
+    return None
+
+
+def create_test_task_entry(db: Session, entry: schemas.TestTaskEntryCreate):
+    db_entry = models.TestTaskEntry(**entry.model_dump())
+    db.add(db_entry)
+    db.commit()
+    db.refresh(db_entry)
+    return db_entry
diff --git a/backend/app/main.py b/backend/app/main.py
index d1a4cc36..8a3539e2 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,6 +1,4 @@
 from collections import defaultdict
-import datetime
-from typing import Annotated
 from fastapi import (
     APIRouter,
     FastAPI,
@@ -8,7 +6,6 @@ from fastapi import (
     Depends,
     HTTPException,
     BackgroundTasks,
-    Header,
     Response,
 )
 from sqlalchemy.orm import Session
@@ -29,6 +26,7 @@ from database import Base, engine, get_db, SessionLocal
 from utils import check_user_level
 import config
 from security import jwt_cookie, get_jwt_user
+from routes.tests import testRouter
 
 websocket_users = defaultdict(lambda: defaultdict(set))
 websocket_users_global = defaultdict(set)
@@ -72,8 +70,6 @@ usersRouter = APIRouter(prefix="/users", tags=["users"])
 sessionsRouter = APIRouter(prefix="/sessions", tags=["sessions"])
 studyRouter = APIRouter(prefix="/studies", tags=["studies"])
 websocketRouter = APIRouter(prefix="/ws", tags=["websocket"])
-webhookRouter = APIRouter(prefix="/webhooks", tags=["webhook"])
-surveyRouter = APIRouter(prefix="/surveys", tags=["surveys"])
 
 
 @v1Router.get("/health", status_code=status.HTTP_204_NO_CONTENT)
@@ -963,190 +959,6 @@ def propagate_presence(
     return
 
 
-@studyRouter.post("", status_code=status.HTTP_201_CREATED)
-def study_create(
-    study: schemas.StudyCreate,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    if not check_user_level(current_user, models.UserType.ADMIN):
-        raise HTTPException(
-            status_code=401, detail="You do not have permission to create a study"
-        )
-    return crud.create_study(db, study).id
-
-
-@studyRouter.get("", response_model=list[schemas.Study])
-def studies_read(
-    db: Session = Depends(get_db),
-):
-    return crud.get_studies(db)
-
-
-@studyRouter.get("/{study_id}", response_model=schemas.Study)
-def study_read(
-    study_id: int,
-    db: Session = Depends(get_db),
-):
-    study = crud.get_study(db, study_id)
-    if study is None:
-        raise HTTPException(status_code=404, detail="Study not found")
-    return study
-
-
-@studyRouter.patch("/{study_id}", status_code=status.HTTP_204_NO_CONTENT)
-def study_update(
-    study_id: int,
-    studyUpdate: schemas.StudyCreate,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    if not check_user_level(current_user, models.UserType.ADMIN):
-        raise HTTPException(
-            status_code=401, detail="You do not have permission to update a study"
-        )
-
-    study = crud.get_study(db, study_id)
-    if study is None:
-        raise HTTPException(status_code=404, detail="Study not found")
-
-    crud.update_study(db, studyUpdate, study_id)
-
-
-@studyRouter.delete("/{study_id}", status_code=status.HTTP_204_NO_CONTENT)
-def study_delete(
-    study_id: int,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    if not check_user_level(current_user, models.UserType.ADMIN):
-        raise HTTPException(
-            status_code=401, detail="You do not have permission to delete a study"
-        )
-
-    study = crud.get_study(db, study_id)
-    if study is None:
-        raise HTTPException(status_code=404, detail="Study not found")
-
-    crud.delete_study(db, study_id)
-
-
-@studyRouter.post("/{study_id}/users/{user_id}", status_code=status.HTTP_201_CREATED)
-def study_add_user(
-    study_id: int,
-    user_id: int,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    user = crud.get_user(db, user_id)
-
-    if user != current_user and not check_user_level(
-        current_user, models.UserType.ADMIN
-    ):
-        raise HTTPException(
-            status_code=401,
-            detail="You do not have permission to add a user to a study",
-        )
-
-    study = crud.get_study(db, study_id)
-    if study is None:
-        raise HTTPException(status_code=404, detail="Study not found")
-
-    if user is None:
-        raise HTTPException(status_code=404, detail="User not found")
-
-    if user in study.users:
-        raise HTTPException(status_code=400, detail="User already exists in this study")
-
-    crud.add_user_to_study(db, study_id, user)
-
-
-@studyRouter.delete(
-    "/{study_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT
-)
-def study_delete_user(
-    study_id: int,
-    user_id: int,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    if not check_user_level(current_user, models.UserType.ADMIN):
-        raise HTTPException(
-            status_code=401,
-            detail="You do not have permission to add a user to a study",
-        )
-
-    study = crud.get_study(db, study_id)
-    if study is None:
-        raise HTTPException(status_code=404, detail="Study not found")
-
-    user = crud.get_user(db, user_id)
-    if user is None:
-        raise HTTPException(status_code=404, detail="User not found")
-
-    if user not in study.users:
-        raise HTTPException(status_code=400, detail="User does not exist in this study")
-
-    crud.remove_user_from_study(db, study_id, user)
-
-
-@studyRouter.post(
-    "/{study_id}/surveys/{survey_id}", status_code=status.HTTP_201_CREATED
-)
-def study_add_survey(
-    study_id: int,
-    survey_id: int,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    if not check_user_level(current_user, models.UserType.ADMIN):
-        raise HTTPException(
-            status_code=401,
-            detail="You do not have permission to add a survey to a study",
-        )
-
-    study = crud.get_study(db, study_id)
-    if study is None:
-        raise HTTPException(status_code=404, detail="Study not found")
-    survey = crud.get_survey(db, survey_id)
-    if survey is None:
-        raise HTTPException(status_code=404, detail="Survey not found")
-    if survey in study.surveys:
-        raise HTTPException(
-            status_code=400, detail="Survey already exists in this study"
-        )
-
-    crud.add_survey_to_study(db, study_id, survey)
-
-
-@studyRouter.delete(
-    "/{study_id}/surveys/{survey_id}", status_code=status.HTTP_204_NO_CONTENT
-)
-def study_delete_survey(
-    study_id: int,
-    survey_id: int,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    if not check_user_level(current_user, models.UserType.ADMIN):
-        raise HTTPException(
-            status_code=401,
-            detail="You do not have permission to add a survey to a study",
-        )
-    study = crud.get_study(db, study_id)
-    if study is None:
-        raise HTTPException(status_code=404, detail="Study not found")
-    survey = crud.get_survey(db, survey_id)
-    if survey is None:
-        raise HTTPException(status_code=404, detail="Survey not found")
-    if survey not in study.surveys:
-        raise HTTPException(
-            status_code=400, detail="Survey does not exist in this study"
-        )
-
-    crud.remove_survey_from_study(db, study_id, survey)
-
-
 @websocketRouter.websocket("/sessions/{session_id}")
 async def websocket_session(
     session_id: int, token: str, websocket: WebSocket, db: Session = Depends(get_db)
@@ -1199,7 +1011,7 @@ async def websocket_session(
 
 
 @websocketRouter.websocket("/global")
-async def websocket_session(
+async def websocket_global(
     token: str, websocket: WebSocket, db: Session = Depends(get_db)
 ):
     try:
@@ -1237,356 +1049,11 @@ async def websocket_session(
             pass
 
 
-@webhookRouter.post("/sessions", status_code=status.HTTP_202_ACCEPTED)
-async def webhook_session(
-    webhook: schemas.CalComWebhook,
-    x_cal_signature_256: Annotated[str | None, Header()] = None,
-    db: Session = Depends(get_db),
-):
-
-    # TODO: Fix. Signature is a hash, not the secret
-    # https://cal.com/docs/core-features/webhooks#adding-a-custom-payload-template
-    # if x_cal_signature_256 != config.CALCOM_SECRET:
-    #    raise HTTPException(status_code=401, detail="Invalid secret")
-
-    if webhook.triggerEvent == "BOOKING_CREATED":
-        start_time = datetime.datetime.fromisoformat(webhook.payload["startTime"])
-        start_time -= datetime.timedelta(hours=1)
-        end_time = datetime.datetime.fromisoformat(webhook.payload["endTime"])
-        end_time += datetime.timedelta(hours=1)
-        attendes = webhook.payload["attendees"]
-        emails = [attendee["email"] for attendee in attendes if attendee != None]
-        print(emails)
-        db_users = [
-            crud.get_user_by_email(db, email) for email in emails if email != None
-        ]
-        print(db_users)
-        users = [user for user in db_users if user != None]
-
-        if users:
-            db_session = crud.create_session_with_users(db, users, start_time, end_time)
-        else:
-            raise HTTPException(status_code=404, detail="Users not found")
-
-        return
-
-    raise HTTPException(status_code=400, detail="Invalid trigger event")
-
-
-@surveyRouter.post("", status_code=status.HTTP_201_CREATED)
-def create_survey(
-    survey: schemas.SurveyCreate,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    if not check_user_level(current_user, models.UserType.ADMIN):
-        raise HTTPException(
-            status_code=401, detail="You do not have permission to create a survey"
-        )
-
-    return crud.create_survey(db, survey).id
-
-
-@surveyRouter.get("", response_model=list[schemas.Survey])
-def get_surveys(
-    db: Session = Depends(get_db),
-):
-    return crud.get_surveys(db)
-
-
-@surveyRouter.get("/{survey_id}", response_model=schemas.Survey)
-def get_survey(
-    survey_id: int,
-    db: Session = Depends(get_db),
-):
-    survey = crud.get_survey(db, survey_id)
-    if survey is None:
-        raise HTTPException(status_code=404, detail="Survey not found")
-
-    return survey
-
-
-@surveyRouter.delete("/{survey_id}", status_code=status.HTTP_204_NO_CONTENT)
-def delete_survey(
-    survey_id: int,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    if not check_user_level(current_user, models.UserType.ADMIN):
-        raise HTTPException(
-            status_code=401, detail="You do not have permission to delete a survey"
-        )
-
-    crud.delete_survey(db, survey_id)
-
-
-@surveyRouter.post("/{survey_id}/groups", status_code=status.HTTP_201_CREATED)
-def add_group_to_survey(
-    survey_id: int,
-    groupc: schemas.SurveySurveyAddGroup,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    if not check_user_level(current_user, models.UserType.ADMIN):
-        raise HTTPException(
-            status_code=401,
-            detail="You do not have permission to add a group to a survey",
-        )
-
-    survey = crud.get_survey(db, survey_id)
-    if survey is None:
-        raise HTTPException(status_code=404, detail="Survey not found")
-
-    group = crud.get_survey_group(db, groupc.group_id)
-    if group is None:
-        raise HTTPException(status_code=404, detail="Survey group not found")
-
-    return crud.add_group_to_survey(db, survey_id, group)
-
-
-@surveyRouter.delete(
-    "/{survey_id}/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT
-)
-def remove_group_from_survey(
-    survey_id: int,
-    group_id: int,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    if not check_user_level(current_user, models.UserType.ADMIN):
-        raise HTTPException(
-            status_code=401,
-            detail="You do not have permission to remove a group from a survey",
-        )
-
-    if not crud.get_survey(db, survey_id):
-        raise HTTPException(status_code=404, detail="Survey not found")
-
-    if not crud.get_survey_group(db, group_id):
-        raise HTTPException(status_code=404, detail="Survey group not found")
-
-    crud.remove_group_from_survey(db, survey_id, group_id)
-
-
-@surveyRouter.post("/groups", status_code=status.HTTP_201_CREATED)
-def create_survey_group(
-    group: schemas.SurveyGroupCreate,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    if not check_user_level(current_user, models.UserType.ADMIN):
-        raise HTTPException(
-            status_code=401,
-            detail="You do not have permission to create a survey group",
-        )
-
-    return crud.create_survey_group(db, group).id
-
-
-@surveyRouter.get("/groups", response_model=list[schemas.SurveyGroup])
-def get_survey_groups(
-    db: Session = Depends(get_db),
-):
-    return crud.get_survey_groups(db)
-
-
-@surveyRouter.get("/groups/{group_id}", response_model=schemas.SurveyGroup)
-def get_survey_group(
-    group_id: int,
-    db: Session = Depends(get_db),
-):
-    group = crud.get_survey_group(db, group_id)
-    if group is None:
-        raise HTTPException(status_code=404, detail="Survey group not found")
-
-    return group
-
-
-@surveyRouter.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
-def delete_survey_group(
-    group_id: int,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    if not check_user_level(current_user, models.UserType.ADMIN):
-        raise HTTPException(
-            status_code=401,
-            detail="You do not have permission to delete a survey group",
-        )
-
-    if not crud.get_survey_group(db, group_id):
-        raise HTTPException(status_code=404, detail="Survey group not found")
-
-    crud.delete_survey_group(db, group_id)
-
-
-@surveyRouter.post("/groups/{group_id}/items", status_code=status.HTTP_201_CREATED)
-def add_item_to_survey_group(
-    group_id: int,
-    question: schemas.SurveyGroupAddQuestion,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    if not check_user_level(current_user, models.UserType.ADMIN):
-        raise HTTPException(
-            status_code=401,
-            detail="You do not have permission to add an item to a survey group",
-        )
-
-    item = crud.get_survey_question(db, question.question_id)
-    if item is None:
-        raise HTTPException(status_code=404, detail="Survey question not found")
-
-    return crud.add_item_to_survey_group(db, group_id, item)
-
-
-@surveyRouter.delete(
-    "/groups/{group_id}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT
-)
-def remove_item_from_survey_group(
-    group_id: int,
-    item_id: int,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    if not check_user_level(current_user, models.UserType.ADMIN):
-        raise HTTPException(
-            status_code=401,
-            detail="You do not have permission to remove an item from a survey group",
-        )
-
-    if not crud.get_survey_group(db, group_id):
-        raise HTTPException(status_code=404, detail="Survey group not found")
-
-    if not crud.get_survey_question(db, item_id):
-        raise HTTPException(status_code=404, detail="Survey question not found")
-
-    crud.remove_item_from_survey_group(db, group_id, item_id)
-
-
-@surveyRouter.post("/items", status_code=status.HTTP_201_CREATED)
-def create_survey_question(
-    question: schemas.SurveyQuestionCreate,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    if not check_user_level(current_user, models.UserType.ADMIN):
-        raise HTTPException(
-            status_code=401,
-            detail="You do not have permission to create a survey question",
-        )
-
-    return crud.create_survey_question(db, question).id
-
-
-@surveyRouter.get("/items", response_model=list[schemas.SurveyQuestion])
-def get_survey_questions(
-    db: Session = Depends(get_db),
-):
-    return crud.get_survey_questions(db)
-
-
-@surveyRouter.get("/items/{question_id}", response_model=schemas.SurveyQuestion)
-def get_survey_question(
-    question_id: int,
-    db: Session = Depends(get_db),
-):
-    question = crud.get_survey_question(db, question_id)
-    if question is None:
-        raise HTTPException(status_code=404, detail="Survey question not found")
-
-    return question
-
-
-@surveyRouter.delete("/items/{question_id}", status_code=status.HTTP_204_NO_CONTENT)
-def delete_survey_question(
-    question_id: int,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    if not check_user_level(current_user, models.UserType.ADMIN):
-        raise HTTPException(
-            status_code=401,
-            detail="You do not have permission to delete a survey question",
-        )
-
-    if not crud.get_survey_question(db, question_id):
-        raise HTTPException(status_code=404, detail="Survey question not found")
-
-    crud.delete_survey_question(db, question_id)
-
-
-@surveyRouter.post("/responses", status_code=status.HTTP_201_CREATED)
-def create_survey_response(
-    response: schemas.SurveyResponseCreate,
-    db: Session = Depends(get_db),
-):
-    return crud.create_survey_response(db, response).id
-
-
-@surveyRouter.get("/responses/{survey_id}", response_model=list[schemas.SurveyResponse])
-def get_survey_responses(
-    survey_id: int,
-    db: Session = Depends(get_db),
-    current_user: schemas.User = Depends(get_jwt_user),
-):
-    if not check_user_level(current_user, models.UserType.ADMIN):
-        raise HTTPException(
-            status_code=401,
-            detail="You do not have permission to view survey responses",
-        )
-
-    if not crud.get_survey(db, survey_id):
-        raise HTTPException(status_code=404, detail="Survey not found")
-
-    return crud.get_survey_responses(db, survey_id)
-
-
-@surveyRouter.post("/info/{survey_id}", status_code=status.HTTP_201_CREATED)
-def create_survey_info(
-    survey_id: int,
-    info: schemas.SurveyResponseInfoCreate,
-    db: Session = Depends(get_db),
-):
-    if not crud.get_survey(db, survey_id):
-        raise HTTPException(status_code=404, detail="Survey not found")
-
-    return crud.create_survey_response_info(db, info)
-
-
-@surveyRouter.get("/{survey_id}/score/{sid}", response_model=dict)
-def get_survey_score(
-    survey_id: int,
-    sid: str,
-    db: Session = Depends(get_db),
-):
-    if not crud.get_survey(db, survey_id):
-        raise HTTPException(status_code=404, detail="Survey not found")
-
-    responses = crud.get_survey_responses(db, sid)
-
-    score = 0
-    total = 0
-    for response in responses:
-        question = crud.get_survey_question(db, response.question_id)
-        if not question:
-            continue
-        total += 1
-        if response.selected_id == question.correct:
-            score += 1
-
-    return {
-        "survey_id": survey_id,
-        "score": round((score / total) * 100 if total > 0 else 0, 2),
-    }
-
-
 v1Router.include_router(authRouter)
 v1Router.include_router(usersRouter)
 v1Router.include_router(sessionsRouter)
 v1Router.include_router(studyRouter)
 v1Router.include_router(websocketRouter)
-v1Router.include_router(webhookRouter)
-v1Router.include_router(surveyRouter)
+v1Router.include_router(testRouter)
 apiRouter.include_router(v1Router)
 app.include_router(apiRouter)
diff --git a/backend/app/models.py b/backend/app/models.py
index 09c47a7d..03a0f2e4 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -180,143 +180,3 @@ class MessageFeedback(Base):
 
     def raw(self):
         return [self.id, self.message_id, self.start, self.end, self.content, self.date]
-
-
-class TestTyping(Base):
-    __tablename__ = "test_typing"
-
-    id = Column(Integer, primary_key=True, index=True)
-    created_at = Column(DateTime, default=datetime_aware)
-    code = Column(String)
-    entries = relationship("TestTypingEntry", backref="typing")
-
-
-class TestTypingEntry(Base):
-    __tablename__ = "test_typing_entry"
-
-    id = Column(Integer, primary_key=True, index=True)
-    typing_id = Column(Integer, ForeignKey("test_typing.id"), index=True)
-    exerciceId = Column(Integer)
-    position = Column(Integer)
-    downtime = Column(Integer)
-    uptime = Column(Integer)
-    keyCode = Column(Integer)
-    keyValue = Column(String)
-
-
-class SurveyGroupQuestion(Base):
-    __tablename__ = "survey_group_questions"
-
-    group_id = Column(Integer, ForeignKey("survey_groups.id"), primary_key=True)
-    question_id = Column(Integer, ForeignKey("survey_questions.id"), primary_key=True)
-
-
-class SurveyQuestion(Base):
-    __tablename__ = "survey_questions"
-
-    id = Column(Integer, primary_key=True, index=True)
-    question = Column(String)
-    correct = Column(Integer)
-    option1 = Column(String)
-    option2 = Column(String)
-    option3 = Column(String)
-    option4 = Column(String)
-    option5 = Column(String)
-    option6 = Column(String)
-    option7 = Column(String)
-    option8 = Column(String)
-
-
-class SurveyGroup(Base):
-    __tablename__ = "survey_groups"
-
-    id = Column(Integer, primary_key=True, index=True)
-    title = Column(String)
-    demo = Column(String, default=False)
-    questions = relationship(
-        "SurveyQuestion", secondary="survey_group_questions", backref="group"
-    )
-
-
-class SurveySurvey(Base):
-    __tablename__ = "survey_surveys"
-
-    id = Column(Integer, primary_key=True, index=True)
-    title = Column(String)
-    groups = relationship(
-        "SurveyGroup", secondary="survey_survey_groups", backref="survey"
-    )
-    studies = relationship("Study", secondary="study_surveys", back_populates="surveys")
-
-
-class SurveySurveyGroup(Base):
-    __tablename__ = "survey_survey_groups"
-
-    survey_id = Column(Integer, ForeignKey("survey_surveys.id"), primary_key=True)
-    group_id = Column(Integer, ForeignKey("survey_groups.id"), primary_key=True)
-
-
-class SurveyResponse(Base):
-    __tablename__ = "survey_responses"
-
-    id = Column(Integer, primary_key=True, index=True)
-    code = Column(String)
-    sid = Column(String)
-    uid = Column(Integer, ForeignKey("users.id"), default=None)
-    created_at = Column(DateTime, default=datetime_aware)
-    survey_id = Column(Integer, ForeignKey("survey_surveys.id"))
-    group_id = Column(Integer, ForeignKey("survey_groups.id"))
-    question_id = Column(Integer, ForeignKey("survey_questions.id"))
-    selected_id = Column(Integer)
-    response_time = Column(Float)
-    text = Column(String)
-
-
-class SurveyResponseInfo(Base):
-    __tablename__ = "survey_response_info"
-
-    id = Column(Integer, primary_key=True, index=True)
-    sid = Column(String)
-    birthyear = Column(Integer)
-    gender = Column(String)
-    primary_language = Column(String)
-    other_language = Column(String)
-    education = Column(String)
-
-
-class Study(Base):
-    __tablename__ = "studies"
-    id = Column(Integer, primary_key=True, index=True)
-    title = Column(String)
-    description = Column(String)
-    start_date = Column(DateTime)
-    end_date = Column(DateTime)
-    chat_duration = Column(Integer)
-
-    users = relationship("User", secondary="study_users", back_populates="studies")
-    surveys = relationship(
-        "SurveySurvey", secondary="study_surveys", back_populates="studies"
-    )
-    tests = relationship("StudyTests", backref="study")
-
-
-class StudyUser(Base):
-    __tablename__ = "study_users"
-
-    study_id = Column(Integer, ForeignKey("studies.id"), primary_key=True)
-    user_id = Column(Integer, ForeignKey("users.id"), primary_key=True)
-
-
-class StudySurvey(Base):
-    __tablename__ = "study_surveys"
-
-    study_id = Column(Integer, ForeignKey("studies.id"), primary_key=True)
-    survey_id = Column(Integer, ForeignKey("survey_surveys.id"), primary_key=True)
-
-
-class StudyTests(Base):
-    __tablename__ = "study_tests"
-
-    study_id = Column(Integer, ForeignKey("studies.id"), primary_key=True)
-    type = Column(String)
-    survey_id = Column(Integer, ForeignKey("survey_surveys.id"), nullable=True)
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
new file mode 100644
index 00000000..6185b5b5
--- /dev/null
+++ b/backend/app/models/__init__.py
@@ -0,0 +1,180 @@
+from sqlalchemy import (
+    Column,
+    Float,
+    Integer,
+    String,
+    Boolean,
+    ForeignKey,
+    DateTime,
+    UniqueConstraint,
+)
+from sqlalchemy.orm import relationship
+from enum import Enum
+
+from database import Base
+import datetime
+from utils import datetime_aware
+
+from models.studies import *
+from models.tests import *
+
+
+class UserType(Enum):
+    ADMIN = 0
+    TUTOR = 1
+    STUDENT = 2
+
+
+class Contact(Base):
+    __tablename__ = "contacts"
+
+    user_id = Column(Integer, ForeignKey("users.id"), primary_key=True, index=True)
+    contact_id = Column(Integer, ForeignKey("users.id"), primary_key=True, index=True)
+
+    UniqueConstraint("user_id", "contact_id", name="unique_contact")
+
+
+class User(Base):
+    __tablename__ = "users"
+
+    id = Column(Integer, primary_key=True, index=True)
+    email = Column(String, unique=True, index=True)
+    nickname = Column(String, index=True)
+    password = Column(String)
+    type = Column(Integer, default=UserType.STUDENT.value)
+    is_active = Column(Boolean, default=True)
+    availability = Column(String, default=0)
+    ui_language = Column(String, default="fr")
+    home_language = Column(String, default="en")
+    target_language = Column(String, default="fr")
+    birthdate = Column(DateTime, default=None)
+    gender = Column(String, default=None)
+    calcom_link = Column(String, default="")
+    last_survey = Column(DateTime, default=None)
+
+    sessions = relationship(
+        "Session", secondary="user_sessions", back_populates="users"
+    )
+
+    contacts = relationship(
+        "User",
+        secondary="contacts",
+        primaryjoin=(id == Contact.user_id),
+        secondaryjoin=(id == Contact.contact_id),
+        back_populates="contacts",
+    )
+
+    contact_of = relationship(
+        "User",
+        secondary="contacts",
+        primaryjoin=(id == Contact.contact_id),
+        secondaryjoin=(id == Contact.user_id),
+        back_populates="contacts",
+    )
+
+    studies = relationship("Study", secondary="study_users", back_populates="users")
+
+
+class UserSurveyWeekly(Base):
+    __tablename__ = "users_survey_weekly"
+
+    id = Column(Integer, primary_key=True, index=True)
+    created_at = Column(DateTime, default=datetime_aware)
+    user_id = Column(Integer, ForeignKey("users.id"))
+    q1 = Column(Float)
+    q2 = Column(Float)
+    q3 = Column(Float)
+    q4 = Column(Float)
+
+
+class Session(Base):
+    __tablename__ = "sessions"
+
+    id = Column(Integer, primary_key=True, index=True)
+    created_at = Column(DateTime, default=datetime_aware)
+    is_active = Column(Boolean, default=True)
+    start_time = Column(DateTime, default=datetime_aware)
+    end_time = Column(
+        DateTime,
+        default=lambda: datetime_aware() + datetime.timedelta(hours=12),
+    )
+    language = Column(String, default="fr")
+
+    users = relationship("User", secondary="user_sessions", back_populates="sessions")
+
+
+class SessionSatisfy(Base):
+    __tablename__ = "session_satisfy"
+
+    id = Column(Integer, primary_key=True, index=True)
+    user_id = Column(Integer, ForeignKey("users.id"))
+    session_id = Column(Integer, ForeignKey("sessions.id"))
+    created_at = Column(DateTime, default=datetime_aware)
+    usefullness = Column(Integer)
+    easiness = Column(Integer)
+    remarks = Column(String)
+
+
+class UserSession(Base):
+    __tablename__ = "user_sessions"
+
+    user_id = Column(Integer, ForeignKey("users.id"), primary_key=True, index=True)
+    session_id = Column(String, ForeignKey("sessions.id"), primary_key=True, index=True)
+
+
+class Message(Base):
+    __tablename__ = "messages"
+
+    id = Column(Integer, primary_key=True, index=True)
+    message_id = Column(String)
+    content = Column(String)
+    user_id = Column(Integer, ForeignKey("users.id"))
+    session_id = Column(Integer, ForeignKey("sessions.id"))
+    created_at = Column(DateTime, default=datetime_aware)
+    reply_to_message_id = Column(
+        Integer, ForeignKey("messages.message_id"), nullable=True
+    )
+
+    feedbacks = relationship("MessageFeedback", backref="message")
+    replies = relationship(
+        "Message", backref="parent_message", remote_side=[message_id]
+    )
+
+    def raw(self):
+        return [
+            self.id,
+            self.message_id,
+            self.content,
+            self.user_id,
+            self.session_id,
+            self.reply_to_message_id,
+            self.created_at,
+        ]
+
+    feedbacks = relationship("MessageFeedback", backref="message")
+
+
+class MessageMetadata(Base):
+    __tablename__ = "message_metadata"
+
+    id = Column(Integer, primary_key=True, index=True)
+    message_id = Column(Integer, ForeignKey("messages.id"))
+    message = Column(String)
+    date = Column(Integer)
+
+    def raw(self):
+        return [self.id, self.message_id, self.message, self.date]
+
+
+class MessageFeedback(Base):
+    __tablename__ = "message_feedbacks"
+
+    id = Column(Integer, primary_key=True, index=True)
+    message_id = Column(Integer, ForeignKey("messages.id"))
+    start = Column(Integer)
+    end = Column(Integer)
+    content = Column(String, default="")
+    date = Column(DateTime, default=datetime_aware)
+
+    def raw(self):
+        return [self.id, self.message_id, self.start, self.end, self.content, self.date]
diff --git a/backend/app/models/studies.py b/backend/app/models/studies.py
new file mode 100644
index 00000000..f817514b
--- /dev/null
+++ b/backend/app/models/studies.py
@@ -0,0 +1,41 @@
+from database import Base
+
+from sqlalchemy import (
+    Column,
+    Integer,
+    String,
+    ForeignKey,
+    DateTime,
+)
+from sqlalchemy.orm import relationship
+
+
+class Study(Base):
+    __tablename__ = "studies"
+    id = Column(Integer, primary_key=True, index=True)
+    title = Column(String)
+    description = Column(String)
+    start_date = Column(DateTime)
+    end_date = Column(DateTime)
+    week_duration = Column(Integer)
+    consent_participation = Column(String)
+    consent_privacy = Column(String)
+    consent_rights = Column(String)
+    consent_study_data = Column(String)
+
+    users = relationship("User", secondary="study_users")
+    tests = relationship("Test", secondary="study_tests")
+
+
+class StudyUser(Base):
+    __tablename__ = "study_users"
+
+    study_id = Column(Integer, ForeignKey("studies.id"), primary_key=True)
+    user_id = Column(Integer, ForeignKey("users.id"), primary_key=True)
+
+
+class StudyTest(Base):
+    __tablename__ = "study_tests"
+
+    study_id = Column(Integer, ForeignKey("studies.id"), primary_key=True)
+    test_id = Column(Integer, ForeignKey("tests.id"), primary_key=True)
diff --git a/backend/app/models/tests.py b/backend/app/models/tests.py
new file mode 100644
index 00000000..2041a7a0
--- /dev/null
+++ b/backend/app/models/tests.py
@@ -0,0 +1,184 @@
+from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, String
+from sqlalchemy.orm import relationship, validates
+from utils import datetime_aware
+from database import Base
+
+
+class TestTyping(Base):
+    __tablename__ = "test_typings"
+
+    test_id = Column(Integer, ForeignKey("tests.id"), primary_key=True)
+    text = Column(String, nullable=False)
+    repeat = Column(Integer, nullable=False, default=1)
+    duration = Column(Integer, nullable=False, default=0)
+
+    test = relationship(
+        "Test", uselist=False, back_populates="test_typing", lazy="selectin"
+    )
+
+
+class TestTypingEntry(Base):
+    __tablename__ = "test_typing_entries"
+
+    id = Column(Integer, primary_key=True, index=True)
+    test_id = Column(Integer, ForeignKey("test_typings.test_id"), index=True)
+    code = Column(String, nullable=True)
+    user_id = Column(Integer, ForeignKey("users.id"), default=None)
+    created_at = Column(DateTime, default=datetime_aware)
+    position = Column(Integer, nullable=False)
+    downtime = Column(Integer, nullable=False)
+    uptime = Column(Integer, nullable=False)
+    key_code = Column(Integer, nullable=False)
+    key_value = Column(String, nullable=False)
+
+    test_typing = relationship("TestTyping")
+    user = relationship("User")
+
+
+class TestTask(Base):
+    __tablename__ = "test_tasks"
+    test_id = Column(Integer, ForeignKey("tests.id"), primary_key=True)
+    title = Column(String, nullable=False)
+
+    test = relationship(
+        "Test", uselist=False, back_populates="test_task", lazy="selectin"
+    )
+    groups = relationship("TestTaskGroup", secondary="test_task_task_groups")
+
+
+class Test(Base):
+    __tablename__ = "tests"
+
+    id = Column(Integer, primary_key=True, index=True)
+
+    test_typing = relationship(
+        "TestTyping", uselist=False, back_populates="test", lazy="selectin"
+    )
+    test_task = relationship(
+        "TestTask", uselist=False, back_populates="test", lazy="selectin"
+    )
+
+    @validates("test_typing")
+    def adjust_test_typing(self, _, value) -> TestTyping | None:
+        if value:
+            return TestTyping(**value, test_id=self.id)
+
+    @validates("test_task")
+    def adjust_test_task(self, _, value) -> TestTask | None:
+        if value:
+            return TestTask(**value, test_id=self.id)
+
+
+class TestTaskTaskGroup(Base):
+    __tablename__ = "test_task_task_groups"
+
+    test_task_id = Column(Integer, ForeignKey("test_tasks.test_id"), primary_key=True)
+    group_id = Column(Integer, ForeignKey("test_task_groups.id"), primary_key=True)
+
+
+class TestTaskGroup(Base):
+    __tablename__ = "test_task_groups"
+    id = Column(Integer, primary_key=True, index=True)
+    test_task_id = Column(Integer, ForeignKey("test_tasks.test_id"), index=True)
+    title = Column(String, nullable=False)
+    demo = Column(String, default=False)
+
+    questions = relationship(
+        "TestTaskQuestion",
+        secondary="test_task_group_questions",
+    )
+
+
+class TestTaskGroupQuestion(Base):
+    __tablename__ = "test_task_group_questions"
+    group_id = Column(Integer, ForeignKey("test_task_groups.id"), primary_key=True)
+    question_id = Column(
+        Integer, ForeignKey("test_task_questions.id"), primary_key=True
+    )
+
+
+class TestTaskQuestionQCM(Base):
+    __tablename__ = "test_task_questions_qcm"
+
+    question_id = Column(
+        Integer, ForeignKey("test_task_questions.id"), primary_key=True
+    )
+
+    correct = Column(Integer, nullable=True)
+    option1 = Column(String, nullable=True)
+    option2 = Column(String, nullable=True)
+    option3 = Column(String, nullable=True)
+    option4 = Column(String, nullable=True)
+    option5 = Column(String, nullable=True)
+    option6 = Column(String, nullable=True)
+    option7 = Column(String, nullable=True)
+    option8 = Column(String, nullable=True)
+
+    question = relationship("TestTaskQuestion", back_populates="question_qcm")
+
+
+class TestTaskQuestion(Base):
+    __tablename__ = "test_task_questions"
+    id = Column(Integer, primary_key=True, index=True)
+    question = Column(String, nullable=True)
+
+    question_qcm = relationship(
+        "TestTaskQuestionQCM", uselist=False, back_populates="question"
+    )
+
+    @validates("question_qcm")
+    def adjust_question_qcm(self, _, value) -> TestTaskQuestionQCM | None:
+        if value:
+            return TestTaskQuestionQCM(**value, question_id=self.id)
+
+
+class TestTaskEntryQCM(Base):
+    __tablename__ = "test_task_entries_qcm"
+
+    entry_id = Column(Integer, ForeignKey("test_task_entries.id"), primary_key=True)
+    selected_id = Column(Integer, nullable=False)
+
+    entry = relationship(
+        "TestTaskEntry", uselist=False, back_populates="entry_qcm", lazy="selectin"
+    )
+
+
+class TestTaskEntryGapfill(Base):
+    __tablename__ = "test_task_entries_gapfill"
+
+    entry_id = Column(Integer, ForeignKey("test_task_entries.id"), primary_key=True)
+    text = Column(String, nullable=False)
+
+    entry = relationship(
+        "TestTaskEntry", uselist=False, back_populates="entry_gapfill", lazy="selectin"
+    )
+
+
+class TestTaskEntry(Base):
+    __tablename__ = "test_task_entries"
+
+    id = Column(Integer, primary_key=True, index=True)
+    code = Column(String, nullable=True)
+    user_id = Column(Integer, ForeignKey("users.id"), default=None)
+    created_at = Column(DateTime, default=datetime_aware)
+    test_task_id = Column(Integer, ForeignKey("test_tasks.test_id"), index=True)
+    test_group_id = Column(Integer, ForeignKey("test_task_groups.id"), index=True)
+    question_id = Column(Integer, ForeignKey("test_task_questions.id"), index=True)
+    response_time = Column(Float, nullable=False)
+
+    entry_qcm = relationship(
+        "TestTaskEntryQCM", uselist=False, back_populates="entry", lazy="selectin"
+    )
+    entry_gapfill = relationship(
+        "TestTaskEntryGapfill", uselist=False, back_populates="entry", lazy="selectin"
+    )
+
+    @validates("entry_qcm")
+    def adjust_entry_qcm(self, _, value) -> TestTaskEntryQCM | None:
+        if value:
+            return TestTaskEntryQCM(**value, entry_id=self.id)
+
+    @validates("entry_gapfill")
+    def adjust_entry_gapfill(self, _, value) -> TestTaskEntryGapfill | None:
+        if value:
+            return TestTaskEntryGapfill(**value, entry_id=self.id)
diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/app/routes/decorators.py b/backend/app/routes/decorators.py
new file mode 100644
index 00000000..6cd315b4
--- /dev/null
+++ b/backend/app/routes/decorators.py
@@ -0,0 +1,21 @@
+from typing import Callable
+
+from fastapi import HTTPException
+
+import schemas
+from utils import check_user_level
+
+
+def require_admin(error: str):
+    def decorator(func: Callable):
+        def wrapper(*args, current_user: schemas.User, **kwargs):
+            if not check_user_level(current_user, schemas.UserType.ADMIN):
+                raise HTTPException(
+                    status_code=401,
+                    detail=error,
+                )
+            return func(*args, current_user=current_user, **kwargs)
+
+        return wrapper
+
+    return decorator
diff --git a/backend/app/routes/tests.py b/backend/app/routes/tests.py
new file mode 100644
index 00000000..fab26033
--- /dev/null
+++ b/backend/app/routes/tests.py
@@ -0,0 +1,185 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+import crud
+import schemas
+from database import get_db
+from routes.decorators import require_admin
+
+testRouter = APIRouter(prefix="/tests", tags=["Tests"])
+
+
+@require_admin("You do not have permission to create a test.")
+@testRouter.post("", status_code=status.HTTP_201_CREATED)
+def create_test(
+    test: schemas.TestCreate,
+    db: Session = Depends(get_db),
+):
+    return crud.create_test(db, test).id
+
+
+@testRouter.get("")
+def get_tests(
+    skip: int = 0,
+    db: Session = Depends(get_db),
+):
+    return crud.get_tests(db, skip)
+
+
+@testRouter.get("/{test_id}", response_model=schemas.Test)
+def get_test(
+    test_id: int,
+    db: Session = Depends(get_db),
+):
+    return crud.get_test(db, test_id)
+
+
+@require_admin("You do not have permission to delete a test.")
+@testRouter.delete("/{test_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_test(
+    test_id: int,
+    db: Session = Depends(get_db),
+):
+    return crud.delete_test(db, test_id)
+
+
+@require_admin("You do not have permission to add a group to a task test.")
+@testRouter.post("/{test_id}/groups/{group_id}", status_code=status.HTTP_201_CREATED)
+def add_group_to_test(
+    test_id: int,
+    group_id: int,
+    db: Session = Depends(get_db),
+):
+    test = crud.get_test(db, test_id)
+    if test is None:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail="Test not found"
+        )
+    if test.test_task is None:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Test does not have a task",
+        )
+
+    group = crud.get_group(db, group_id)
+    if group is None:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
+        )
+
+    return crud.add_group_to_test_task(db, test.test_task, group)
+
+
+@require_admin("You do not have permission to remove a group from a task test.")
+@testRouter.delete(
+    "/{test_id}/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT
+)
+def remove_group_from_test(
+    test_id: int,
+    group_id: int,
+    db: Session = Depends(get_db),
+):
+    test = crud.get_test(db, test_id)
+    if test is None:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail="Test not found"
+        )
+    if test.test_task is None:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Test does not have a task",
+        )
+    group = crud.get_group(db, group_id)
+    if group is None:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
+        )
+    return crud.remove_group_from_test_task(db, test.test_task, group)
+
+
+@require_admin("You do not have permission to create a group.")
+@testRouter.post("/groups", status_code=status.HTTP_201_CREATED)
+def create_group(
+    group: schemas.TestTaskGroupCreate,
+    db: Session = Depends(get_db),
+):
+    return crud.create_group(db, group).id
+
+
+@require_admin("You do not have permission to delete a group.")
+@testRouter.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_group(
+    group_id: int,
+    db: Session = Depends(get_db),
+):
+    return crud.delete_group(db, group_id)
+
+
+@require_admin("You do not have permission to add a question to a group.")
+@testRouter.post(
+    "/groups/{group_id}/questions/{question_id}", status_code=status.HTTP_201_CREATED
+)
+def add_question_to_group(
+    group_id: int,
+    question_id: int,
+    db: Session = Depends(get_db),
+):
+    group = crud.get_group(db, group_id)
+    if group is None:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
+        )
+    question = crud.get_question(db, question_id)
+    if question is None:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail="Question not found"
+        )
+    return crud.add_question_to_group(db, group, question)
+
+
+@require_admin("You do not have permission to remove a question from a group.")
+@testRouter.delete(
+    "/groups/{group_id}/questions/{question_id}", status_code=status.HTTP_204_NO_CONTENT
+)
+def remove_question_from_group(
+    group_id: int,
+    question_id: int,
+    db: Session = Depends(get_db),
+):
+    group = crud.get_group(db, group_id)
+    if group is None:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
+        )
+    question = crud.get_question(db, question_id)
+    if question is None:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail="Question not found"
+        )
+    return crud.remove_question_from_group(db, group, question)
+
+
+@require_admin("You do not have permission to create a question.")
+@testRouter.post("/questions", status_code=status.HTTP_201_CREATED)
+def create_question(
+    question: schemas.TestTaskQuestionCreate,
+    db: Session = Depends(get_db),
+):
+    return crud.create_question(db, question).id
+
+
+@require_admin("You do not have permission to delete a question.")
+@testRouter.delete("/questions/{question_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_question(
+    question_id: int,
+    db: Session = Depends(get_db),
+):
+    return crud.delete_question(db, question_id)
+
+
+@testRouter.post("/entries", status_code=status.HTTP_201_CREATED)
+def create_entry(
+    entry: schemas.TestTaskEntryCreate,
+    db: Session = Depends(get_db),
+):
+    return crud.create_test_task_entry(db, entry).id
diff --git a/backend/app/schemas.py b/backend/app/schemas/__init__.py
similarity index 67%
rename from backend/app/schemas.py
rename to backend/app/schemas/__init__.py
index 52fe3011..f8dbff79 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas/__init__.py
@@ -1,6 +1,8 @@
 from pydantic import BaseModel, NaiveDatetime
 
-from models import UserType
+from schemas.studies import *
+from schemas.tests import *
+from schemas.users import *
 
 
 class LoginData(BaseModel):
@@ -15,86 +17,6 @@ class RegisterData(BaseModel):
     is_tutor: bool
 
 
-class User(BaseModel):
-    id: int
-    email: str
-    nickname: str
-    type: int
-    bio: str | None
-    is_active: bool
-    ui_language: str | None
-    home_language: str | None
-    target_language: str | None
-    birthdate: NaiveDatetime | None
-    gender: str | None = None
-    calcom_link: str | None
-    last_survey: NaiveDatetime | None = None
-    availabilities: list[dict] | None = []
-    tutor_list: list[str] | None = []
-    my_tutor: str | None = None
-    my_slots: list[dict] | None = []
-
-    class Config:
-        from_attributes = True
-
-    def to_dict(self):
-        return {
-            "id": self.id,
-            "email": self.email,
-            "nickname": self.nickname,
-            "type": self.type,
-            "availability": self.availability,
-            "is_active": self.is_active,
-            "ui_language": self.ui_language,
-            "home_language": self.home_language,
-            "target_language": self.target_language,
-            "birthdate": self.birthdate.isoformat() if self.birthdate else None,
-        }
-
-
-class UserCreate(BaseModel):
-    email: str
-    nickname: str | None = None
-    password: str
-    type: int = UserType.STUDENT.value
-    bio: str | None = None
-    is_active: bool = True
-    ui_language: str | None = None
-    home_language: str | None = None
-    target_language: str | None = None
-    birthdate: NaiveDatetime | None = None
-    gender: str | None = None
-    calcom_link: str | None = None
-    last_survey: NaiveDatetime | None = None
-    availabilities: list[dict] | None = []
-    tutor_list: list[str] | None = []
-    my_tutor: str | None = None
-    my_slots: list[dict] | None = []
-
-
-class UserUpdate(BaseModel):
-    email: str | None = None
-    nickname: str | None = None
-    password: str | None = None
-    type: int | None = None
-    bio: str | None = None
-    is_active: bool | None = None
-    ui_language: str | None = None
-    home_language: str | None = None
-    target_language: str | None = None
-    birthdate: NaiveDatetime | None = None
-    gender: str | None = None
-    calcom_link: str | None = None
-    last_survey: NaiveDatetime | None = None
-    availabilities: list[dict] | None = []
-    tutor_list: list[str] | None = []
-    my_tutor: str | None = None
-    my_slots: list[dict] | None = None
-
-    class Config:
-        from_attributes = True
-
-
 class ContactCreate(BaseModel):
     user_id: int
 
@@ -355,22 +277,3 @@ class SurveyResponseInfo(BaseModel):
     primary_language: str
     other_language: str
     education: str
-
-
-class Study(BaseModel):
-    id: int
-    title: str
-    description: str
-    start_date: NaiveDatetime
-    end_date: NaiveDatetime
-    chat_duration: int
-    users: list[User]
-    surveys: list[Survey]
-
-
-class StudyCreate(BaseModel):
-    title: str
-    description: str
-    start_date: NaiveDatetime
-    end_date: NaiveDatetime
-    chat_duration: int = 30
diff --git a/backend/app/schemas/studies.py b/backend/app/schemas/studies.py
new file mode 100644
index 00000000..81c47734
--- /dev/null
+++ b/backend/app/schemas/studies.py
@@ -0,0 +1,23 @@
+from pydantic import BaseModel, NaiveDatetime
+
+from schemas.users import User
+from schemas.tests import Test
+
+
+class StudyCreate(BaseModel):
+    title: str
+    description: str
+    start_date: NaiveDatetime
+    end_date: NaiveDatetime
+    week_duration: int = 8
+    consent_participation: str
+    consent_privacy: str
+    consent_rights: str
+    consent_study_data: str
+
+    users: list[User]
+    tests: list[Test]
+
+
+class Study(StudyCreate):
+    id: int
diff --git a/backend/app/schemas/tests.py b/backend/app/schemas/tests.py
new file mode 100644
index 00000000..6f694145
--- /dev/null
+++ b/backend/app/schemas/tests.py
@@ -0,0 +1,122 @@
+from typing_extensions import Self
+from pydantic import BaseModel, model_validator
+
+
+class TestTypingCreate(BaseModel):
+    text: str
+    repeat: int | None = None
+    duration: int | None = None
+
+
+class TestTaskCreate(BaseModel):
+    title: str
+
+
+class TestCreate(BaseModel):
+    # TODO remove
+    id: int | None = None
+    test_typing: TestTypingCreate | None = None
+    test_task: TestTaskCreate | None = None
+
+    @model_validator(mode="after")
+    def check_test_type(self) -> Self:
+        if self.test_typing is None and self.test_task is None:
+            raise ValueError("TypingTest or TaskTest must be provided")
+        if self.test_typing is not None and self.test_task is not None:
+            raise ValueError(
+                "TypingTest and TaskTest cannot be provided at the same time"
+            )
+        return self
+
+
+class TestTaskGroupCreate(BaseModel):
+    title: str
+    demo: bool = False
+
+
+class TestTaskQuestionQCMCreate(BaseModel):
+    correct: int
+    option1: str | None = None
+    option2: str | None = None
+    option3: str | None = None
+    option4: str | None = None
+    option5: str | None = None
+    option6: str | None = None
+    option7: str | None = None
+    option8: str | None = None
+
+
+class TestTaskQuestionCreate(BaseModel):
+    # TODO remove
+    id: int | None = None
+    question: str | None = None
+
+    question_qcm: TestTaskQuestionQCMCreate | None = None
+
+
+class TestTaskGroupQuestionAdd(BaseModel):
+    question_id: int
+    group_id: int
+
+
+class TestTaskGroupAdd(BaseModel):
+    group_id: int
+    task_id: int
+
+
+class TestTaskQuestionQCM(TestTaskQuestionQCMCreate):
+    pass
+
+
+class TestTaskQuestion(BaseModel):
+    id: int
+    question: str | None = None
+    question_qcm: TestTaskQuestionQCM | None = None
+
+
+class TestTaskGroup(TestTaskGroupCreate):
+    id: int
+    questions: list[TestTaskQuestion] = []
+
+
+class TestTask(TestTaskCreate):
+    id: int
+    groups: list[TestTaskGroup] = []
+
+
+class TestTyping(TestTypingCreate):
+    id: int
+
+
+class Test(TestCreate):
+    # TODO add
+    # id: int
+    pass
+
+
+class TestTaskEntryQCMCreate(BaseModel):
+    selected_id: int
+
+
+class TestTaskEntryGapfillCreate(BaseModel):
+    text: str
+
+
+class TestTaskEntryCreate(BaseModel):
+    code: str
+    user_id: int
+    test_task_id: int
+    test_group_id: int
+    test_question_id: int
+    response_time: float
+
+    entry_qcm: TestTaskEntryQCMCreate | None = None
+    entry_gapfill: TestTaskEntryGapfillCreate | None = None
+
+    @model_validator(mode="after")
+    def check_entry_type(self) -> Self:
+        if self.entry_qcm is None and self.entry_gapfill is None:
+            raise ValueError("QCM or Gapfill must be provided")
+        if self.entry_qcm is not None and self.entry_gapfill is not None:
+            raise ValueError("QCM and Gapfill cannot be provided at the same time")
+        return self
diff --git a/backend/app/schemas/users.py b/backend/app/schemas/users.py
new file mode 100644
index 00000000..ff07d5df
--- /dev/null
+++ b/backend/app/schemas/users.py
@@ -0,0 +1,83 @@
+from pydantic import BaseModel, NaiveDatetime
+
+from models import UserType
+
+
+class User(BaseModel):
+    id: int
+    email: str
+    nickname: str
+    type: int
+    bio: str | None
+    is_active: bool
+    ui_language: str | None
+    home_language: str | None
+    target_language: str | None
+    birthdate: NaiveDatetime | None
+    gender: str | None = None
+    calcom_link: str | None
+    last_survey: NaiveDatetime | None = None
+    availabilities: list[dict] | None = []
+    tutor_list: list[str] | None = []
+    my_tutor: str | None = None
+    my_slots: list[dict] | None = []
+
+    class Config:
+        from_attributes = True
+
+    def to_dict(self):
+        return {
+            "id": self.id,
+            "email": self.email,
+            "nickname": self.nickname,
+            "type": self.type,
+            "availability": self.availability,
+            "is_active": self.is_active,
+            "ui_language": self.ui_language,
+            "home_language": self.home_language,
+            "target_language": self.target_language,
+            "birthdate": self.birthdate.isoformat() if self.birthdate else None,
+        }
+
+
+class UserCreate(BaseModel):
+    email: str
+    nickname: str | None = None
+    password: str
+    type: int = UserType.STUDENT.value
+    bio: str | None = None
+    is_active: bool = True
+    ui_language: str | None = None
+    home_language: str | None = None
+    target_language: str | None = None
+    birthdate: NaiveDatetime | None = None
+    gender: str | None = None
+    calcom_link: str | None = None
+    last_survey: NaiveDatetime | None = None
+    availabilities: list[dict] | None = []
+    tutor_list: list[str] | None = []
+    my_tutor: str | None = None
+    my_slots: list[dict] | None = []
+
+
+class UserUpdate(BaseModel):
+    email: str | None = None
+    nickname: str | None = None
+    password: str | None = None
+    type: int | None = None
+    bio: str | None = None
+    is_active: bool | None = None
+    ui_language: str | None = None
+    home_language: str | None = None
+    target_language: str | None = None
+    birthdate: NaiveDatetime | None = None
+    gender: str | None = None
+    calcom_link: str | None = None
+    last_survey: NaiveDatetime | None = None
+    availabilities: list[dict] | None = []
+    tutor_list: list[str] | None = []
+    my_tutor: str | None = None
+    my_slots: list[dict] | None = None
+
+    class Config:
+        from_attributes = True
diff --git a/backend/app/test_main.py b/backend/app/test_main.py
deleted file mode 100644
index dc5a19d6..00000000
--- a/backend/app/test_main.py
+++ /dev/null
@@ -1,106 +0,0 @@
-from fastapi.testclient import TestClient
-import datetime
-
-from .main import app
-import config
-
-client = TestClient(app)
-
-
-def test_read_main():
-    response = client.get("/health")
-    assert response.status_code == 204
-
-
-def test_webhook_create():
-    response = client.post(
-        "/api/v1/webhooks/sessions",
-        headers={"X-Cal-Signature-256": config.CALCOM_SECRET},
-        json={
-            "triggerEvent": "BOOKING_CREATED",
-            "createdAt": "2023-05-24T09:30:00.538Z",
-            "payload": {
-                "type": "60min",
-                "title": "60min between Pro Example and John Doe",
-                "description": "",
-                "additionalNotes": "",
-                "customInputs": {},
-                "startTime": (
-                    datetime.datetime.now() + datetime.timedelta(days=1, hours=1)
-                ).isoformat(),
-                "endTime": (
-                    datetime.datetime.now() + datetime.timedelta(days=1, hours=2)
-                ).isoformat(),
-                "organizer": {
-                    "id": 5,
-                    "name": "Pro Example",
-                    "email": "pro@example.com",
-                    "username": "pro",
-                    "timeZone": "Asia/Kolkata",
-                    "language": {"locale": "en"},
-                    "timeFormat": "h:mma",
-                },
-                "responses": {
-                    "name": {"label": "your_name", "value": "John Doe"},
-                    "email": {
-                        "label": "email_address",
-                        "value": "john.doe@example.com",
-                    },
-                    "location": {
-                        "label": "location",
-                        "value": {"optionValue": "", "value": "inPerson"},
-                    },
-                    "notes": {"label": "additional_notes"},
-                    "guests": {"label": "additional_guests"},
-                    "rescheduleReason": {"label": "reschedule_reason"},
-                },
-                "userFieldsResponses": {},
-                "attendees": [
-                    {
-                        "email": "admin@admin.tld",
-                        "name": "John Doe",
-                        "timeZone": "Asia/Kolkata",
-                        "language": {"locale": "en"},
-                    }
-                ],
-                "location": "Calcom HQ",
-                "destinationCalendar": {
-                    "id": 10,
-                    "integration": "apple_calendar",
-                    "externalId": "https://caldav.icloud.com/1234567/calendars/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX/",
-                    "userId": 5,
-                    "eventTypeId": None,
-                    "credentialId": 1,
-                },
-                "hideCalendarNotes": False,
-                "requiresConfirmation": None,
-                "eventTypeId": 7,
-                "seatsShowAttendees": True,
-                "seatsPerTimeSlot": None,
-                "uid": "bFJeNb2uX8ANpT3JL5EfXw",
-                "appsStatus": [
-                    {
-                        "appName": "Apple Calendar",
-                        "type": "apple_calendar",
-                        "success": 1,
-                        "failures": 0,
-                        "errors": [],
-                        "warnings": [],
-                    }
-                ],
-                "eventTitle": "60min",
-                "eventDescription": "",
-                "price": 0,
-                "currency": "usd",
-                "length": 60,
-                "bookingId": 91,
-                "metadata": {},
-                "status": "ACCEPTED",
-            },
-        },
-    )
-
-    assert response.status_code == 202, (
-        response.status_code,
-        response.content.decode("utf-8"),
-    )
diff --git a/frontend/src/lib/types/study.svelte.ts b/frontend/src/lib/types/study.svelte.ts
index 1e9e67cf..607cd8fc 100644
--- a/frontend/src/lib/types/study.svelte.ts
+++ b/frontend/src/lib/types/study.svelte.ts
@@ -163,7 +163,19 @@ export default class Study {
 		tests: (SurveyTypingSvelte | Survey)[],
 		f: fetchType = fetch
 	): Promise<Study | null> {
-		const id = await createStudyAPI(f, title, description, startDate, endDate, chatDuration, []);
+		const id = await createStudyAPI(
+			f,
+			title,
+			description,
+			startDate,
+			endDate,
+			chatDuration,
+			[],
+			consentParticipation,
+			consentPrivacy,
+			consentRights,
+			consentStudyData
+		);
 
 		if (id) {
 			return new Study(
diff --git a/scripts/surveys/items.csv b/scripts/surveys/items.csv
deleted file mode 100644
index 2f7c95ab..00000000
--- a/scripts/surveys/items.csv
+++ /dev/null
@@ -1,244 +0,0 @@
-id,question,correct,option1,option2,option3,option4,option5,option6,option7,option8
-101,,1,,,,,,,,
-102,,1,,,,,,,,
-103,,1,,,,,,,,
-104,,1,,,,,,,,
-105,,1,,,,,,,,
-106,,1,,,,,,,,
-107,,1,,,,,,,,
-108,,1,,,,,,,,
-109,,1,,,,,,,,
-110,,1,,,,,,,,
-111,,1,,,,,,,,
-112,,1,,,,,,,,
-113,,1,,,,,,,,
-114,,1,,,,,,,,
-115,,1,,,,,,,,
-116,,1,,,,,,,,
-117,,1,,,,,,,,
-118,,1,,,,,,,,
-119,,1,,,,,,,,
-120,,1,,,,,,,,
-121,,1,,,,,,,,
-122,,1,,,,,,,,
-123,,1,,,,,,,,
-124,,1,,,,,,,,
-125,,1,,,,,,,,
-126,,1,,,,,,,,
-127,,1,,,,,,,,
-128,,1,,,,,,,,
-129,,1,,,,,,,,
-130,,1,,,,,,,,
-131,,1,,,,,,,,
-132,,1,,,,,,,,
-133,,1,,,,,,,,
-134,,1,,,,,,,,
-135,,1,,,,,,,,
-136,,1,,,,,,,,
-137,,1,,,,,,,,
-138,,1,,,,,,,,
-139,,1,,,,,,,,
-140,,1,,,,,,,,
-141,,1,,,,,,,,
-142,,1,,,,,,,,
-143,,1,,,,,,,,
-144,,1,,,,,,,,
-145,,1,,,,,,,,
-146,,1,,,,,,,,
-147,,1,,,,,,,,
-148,,1,,,,,,,,
-149,,1,,,,,,,,
-150,,1,,,,,,,,
-151,,1,,,,,,,,
-152,,1,,,,,,,,
-153,,1,,,,,,,,
-154,,1,,,,,,,,
-155,,1,,,,,,,,
-156,,1,,,,,,,,
-157,,1,,,,,,,,
-158,,1,,,,,,,,
-159,,1,,,,,,,,
-160,,1,,,,,,,,
-161,,1,,,,,,,,
-162,,1,,,,,,,,
-163,,1,,,,,,,,
-164,,1,,,,,,,,
-165,,1,,,,,,,,
-166,,1,,,,,,,,
-167,,1,,,,,,,,
-168,,1,,,,,,,,
-169,,1,,,,,,,,
-170,,1,,,,,,,,
-171,,1,,,,,,,,
-172,,1,,,,,,,,
-173,,1,,,,,,,,
-174,,1,,,,,,,,
-175,,1,,,,,,,,
-176,,1,,,,,,,,
-177,,1,,,,,,,,
-178,,1,,,,,,,,
-179,,1,,,,,,,,
-200,"My flowers are beauti<ful>.",-1
-201,"He has a successful car<eer> as a lawyer.",-1
-202,"Sudden noises at night sca<re> me a lot and keep me awake.",-1
-203,"Many people are inj<ured> in road accidents every year.",-1
-204,"Don't pay attention to this rude remark. Just ign<ore> it.",-1
-205,"There has been a recent tr<end> among prosperous families towards a smaller number of children.",-1
-206,"He is irresponsible. You cannot re<ly> on him for help.",-1
-207,"This work is not up to your usu<al> standard.",-1
-208,"You must have been very br<ave> to participate in such a dangerous operation.",-1
-209,"They sat down to eat even though they were not hu<ngry>.",-1
-210,"You must be awa<re> that very few jobs are available.",-1
-211,"The workmen cleaned up the me<ss> before they left.",-1
-212,"The drug was introduced after medical res<earch> indisputably proved its effectiveness.",-1
-213,"Governments often cut budgets in times of financial cri<sis>.",-1
-214,"In a lecture, most of the talking is done by the lecturer. In a seminar, students are expected to part<icipate> in the discussion.",-1
-215,"It's difficult to ass<ess> a person's true knowledge by one or two tests.",-1
-216,"There are a doz<en> eggs in the basket.",-1
-217,"The Far East is one of the most populated reg<ions> of the world.",-1
-218,"She spent her childhood in Europe and most of her ad<ult> life in Asia.",-1
-219,"La<ck> of rain led to a shortage of water in the city.",-1
-220,"His new book will be pub<lished> at the end of this month by a famous University Press.",-1
-221,"She didn't openly attack the plan, but her opposition was imp<licit> in her attitude.",-1
-222,"This sweater is too tight. It needs to be stret<ched>.",-1
-223,"In order to be accepted into the university, he had to impr<ove> his grades.",-1
-224,"He had been expe<lled> from school for stealing.",-1
-225,"Plants receive water from the soil though their ro<ots>.",-1
-226,"The children's games were funny at first, but finally got on the parents' ner<ves>.",-1
-227,"Before writing the final version, the student wrote several dra<fts>.",-1
-228,"I've had my eyes tested and the optician says my vi<sion> is good.",-1
-229,"In a free country, people can apply for any job. They should not be discriminated against on the basis of colour, age, or s<ex>.",-1
-230,"His decision to leave home was not well thought out. It was not based on rat<ional> considerations.",-1
-231,"They insp<ected> all products before sending them out to stores.",-1
-232,"The challenging job required a young, successful and dyn<amic> candidate.",-1
-233,"Even though I don't usually side with you, in this ins<tance> I must admit that you're right.",-1
-234,"If I were you I would con<tact> a good lawyer before taking action.",-1
-235,"The book covers a series of isolated epis<odes> from history.",-1
-236,"She is not a child, but a mat<ure> woman. She can make her own decisions.",-1
-237,"After finishing his degree, he entered upon a new ph<ase> in his career.",-1
-238,"The airport is far away. If you want to ens<ure> that you catch your plane, you have to leave early.",-1
-239,"The plaster on the wall was removed to exp<ose> the original bricks underneath.",-1
-240,"Farmers are introducing innova<tions> that increase the productivity per worker.",-1
-241,"A considerable amount of evidence was accum<ulated> during the investigation.",-1
-242,"Since he is unskilled, he earns low wa<ges>.",-1
-243,"Research ind<icates> that men find it easier to give up smoking than women.",-1
-244,"It is not easy to abs<orb> all this information in such a short time.",-1
-245,"People have proposed all kinds of hypot<heses> about what these things are.",-1
-246,"The lack of money depressed and frust<rated> him.",-1
-247,"The story tells us about a crime and sub<sequent> punishment.",-1
-248,"It's impossible to eva<luate> these results without knowing about the research methods that were used.",-1
-249,"I'm glad we had this opp<ortunity> to talk.",-1
-250,"The differences were so sl<ight> that they went unnoticed.",-1
-251,"To improve the country's economy, the government decided on economic ref<orms>.",-1
-252,"Laws are based upon the principle of jus<tice>.",-1
-253,"Anna intro<duced> her boyfriend to her mother last night.",-1
-254,"The dress you're wearing is lov<ely>.",-1
-255,"They had to cl<imb> a steep mountain to reach the cabin.",-1
-256,"The doctor ex<amined> the patient thoroughly.",-1
-257,"He takes cr<eam> and sugar in his coffee.",-1
-258,"Every year, the organisers li<mit> the number of participants to fifty.",-1
-259,"He usually reads the sport sec<tion> of the newspaper first.",-1
-260,"Teenagers often adm<ire> and worship pop singers.",-1
-1000,,1,,,,
-1001,,1,,,,
-1002,,1,,,,
-1003,,1,,,,
-1004,,1,,,,
-1005,,1,,,,
-1006,,1,,,,
-1007,,1,,,,
-1008,,1,,,,
-1009,,1,,,,
-1010,,1,,,,
-1011,,1,,,,
-1012,,1,,,,
-1013,,1,,,,
-1014,,1,,,,
-1015,,1,,,,
-1016,,1,,,,
-1017,,1,,,,
-1018,,1,,,,
-1019,,1,,,,
-1020,,1,,,,
-1021,,1,,,,
-1022,,1,,,,
-1023,,1,,,,
-1024,,1,,,,
-1025,,1,,,,
-1026,,1,,,,
-1027,,1,,,,
-1028,,1,,,,
-1029,,1,,,,
-1030,,1,,,,
-1031,,1,,,,
-1032,,1,,,,
-1033,,1,,,,
-
-1037,,1,,,,
-1038,,1,,,,
-1039,,1,,,,
-1040,,1,,,,
-1041,,1,,,,
-1042,,1,,,,
-1043,,1,,,,
-1044,,1,,,,
-1045,,1,,,,
-1046,,1,,,,
-1047,,1,,,,
-1048,,1,,,,
-1049,,1,,,,
-1050,,1,,,,
-1051,,1,,,,
-1052,,1,,,,
-1053,,1,,,,
-1054,,1,,,,
-1055,,1,,,,
-1056,,1,,,,
-1057,,1,,,,
-1058,,1,,,,
-1059,,1,,,,
-1060,,1,,,,
-1061,,1,,,,
-1062,,1,,,,
-1063,,1,,,,
-
-1065,,1,,,,
-1066,,1,,,,
-1067,,1,,,,
-1068,,1,,,,
-1069,,1,,,,
-
-1071,,1,,,,
-
-1073,,1,,,,
-
-1076,,1,,,,
-1077,,1,,,,
-1078,,1,,,,
-1079,,1,,,,
-1080,,1,,,,
-1081,,1,,,,
-
-1083,,1,,,,
-1084,,1,,,,
-1085,,1,,,,
-
-1087,,1,,,,
-1088,,1,,,,
-1089,,1,,,,
-
-1091,,1,,,,
-1092,,1,,,,
-1093,,1,,,,
-1094,,1,,,,
-1095,,1,,,,
-1096,,1,,,,
-1097,,1,,,,
-1098,,1,,,,
-1099,,1,,,,
-1100,,1,,,,
-1101,,1,,,,
-1102,,1,,,,
-1103,,1,,,,
-1104,,1,,,,
-1105,,1,,,,
\ No newline at end of file
diff --git a/scripts/surveys/questions_gapfill.csv b/scripts/surveys/questions_gapfill.csv
new file mode 100644
index 00000000..6eaf6afa
--- /dev/null
+++ b/scripts/surveys/questions_gapfill.csv
@@ -0,0 +1,62 @@
+id,question
+200,"My flowers are beauti<ful>."
+201,"He has a successful car<eer> as a lawyer."
+202,"Sudden noises at night sca<re> me a lot and keep me awake."
+203,"Many people are inj<ured> in road accidents every year."
+204,"Don't pay attention to this rude remark. Just ign<ore> it."
+205,"There has been a recent tr<end> among prosperous families towards a smaller number of children."
+206,"He is irresponsible. You cannot re<ly> on him for help."
+207,"This work is not up to your usu<al> standard."
+208,"You must have been very br<ave> to participate in such a dangerous operation."
+209,"They sat down to eat even though they were not hu<ngry>."
+210,"You must be awa<re> that very few jobs are available."
+211,"The workmen cleaned up the me<ss> before they left."
+212,"The drug was introduced after medical res<earch> indisputably proved its effectiveness."
+213,"Governments often cut budgets in times of financial cri<sis>."
+214,"In a lecture, most of the talking is done by the lecturer. In a seminar, students are expected to part<icipate> in the discussion."
+215,"It's difficult to ass<ess> a person's true knowledge by one or two tests."
+216,"There are a doz<en> eggs in the basket."
+217,"The Far East is one of the most populated reg<ions> of the world."
+218,"She spent her childhood in Europe and most of her ad<ult> life in Asia."
+219,"La<ck> of rain led to a shortage of water in the city."
+220,"His new book will be pub<lished> at the end of this month by a famous University Press."
+221,"She didn't openly attack the plan, but her opposition was imp<licit> in her attitude."
+222,"This sweater is too tight. It needs to be stret<ched>."
+223,"In order to be accepted into the university, he had to impr<ove> his grades."
+224,"He had been expe<lled> from school for stealing."
+225,"Plants receive water from the soil though their ro<ots>."
+226,"The children's games were funny at first, but finally got on the parents' ner<ves>."
+227,"Before writing the final version, the student wrote several dra<fts>."
+228,"I've had my eyes tested and the optician says my vi<sion> is good."
+229,"In a free country, people can apply for any job. They should not be discriminated against on the basis of colour, age, or s<ex>."
+230,"His decision to leave home was not well thought out. It was not based on rat<ional> considerations."
+231,"They insp<ected> all products before sending them out to stores."
+232,"The challenging job required a young, successful and dyn<amic> candidate."
+233,"Even though I don't usually side with you, in this ins<tance> I must admit that you're right."
+234,"If I were you I would con<tact> a good lawyer before taking action."
+235,"The book covers a series of isolated epis<odes> from history."
+236,"She is not a child, but a mat<ure> woman. She can make her own decisions."
+237,"After finishing his degree, he entered upon a new ph<ase> in his career."
+238,"The airport is far away. If you want to ens<ure> that you catch your plane, you have to leave early."
+239,"The plaster on the wall was removed to exp<ose> the original bricks underneath."
+240,"Farmers are introducing innova<tions> that increase the productivity per worker."
+241,"A considerable amount of evidence was accum<ulated> during the investigation."
+242,"Since he is unskilled, he earns low wa<ges>."
+243,"Research ind<icates> that men find it easier to give up smoking than women."
+244,"It is not easy to abs<orb> all this information in such a short time."
+245,"People have proposed all kinds of hypot<heses> about what these things are."
+246,"The lack of money depressed and frust<rated> him."
+247,"The story tells us about a crime and sub<sequent> punishment."
+248,"It's impossible to eva<luate> these results without knowing about the research methods that were used."
+249,"I'm glad we had this opp<ortunity> to talk."
+250,"The differences were so sl<ight> that they went unnoticed."
+251,"To improve the country's economy, the government decided on economic ref<orms>."
+252,"Laws are based upon the principle of jus<tice>."
+253,"Anna intro<duced> her boyfriend to her mother last night."
+254,"The dress you're wearing is lov<ely>."
+255,"They had to cl<imb> a steep mountain to reach the cabin."
+256,"The doctor ex<amined> the patient thoroughly."
+257,"He takes cr<eam> and sugar in his coffee."
+258,"Every year, the organisers li<mit> the number of participants to fifty."
+259,"He usually reads the sport sec<tion> of the newspaper first."
+260,"Teenagers often adm<ire> and worship pop singers."
diff --git a/scripts/surveys/questions_qcm.csv b/scripts/surveys/questions_qcm.csv
new file mode 100644
index 00000000..b2a52e64
--- /dev/null
+++ b/scripts/surveys/questions_qcm.csv
@@ -0,0 +1,175 @@
+id,question,correct,option1,option2,option3,option4,option5,option6,option7,option8
+101,,1,,,,,,,,
+102,,1,,,,,,,,
+103,,1,,,,,,,,
+104,,1,,,,,,,,
+105,,1,,,,,,,,
+106,,1,,,,,,,,
+107,,1,,,,,,,,
+108,,1,,,,,,,,
+109,,1,,,,,,,,
+110,,1,,,,,,,,
+111,,1,,,,,,,,
+112,,1,,,,,,,,
+113,,1,,,,,,,,
+114,,1,,,,,,,,
+115,,1,,,,,,,,
+116,,1,,,,,,,,
+117,,1,,,,,,,,
+118,,1,,,,,,,,
+119,,1,,,,,,,,
+120,,1,,,,,,,,
+121,,1,,,,,,,,
+122,,1,,,,,,,,
+123,,1,,,,,,,,
+124,,1,,,,,,,,
+125,,1,,,,,,,,
+126,,1,,,,,,,,
+127,,1,,,,,,,,
+128,,1,,,,,,,,
+129,,1,,,,,,,,
+130,,1,,,,,,,,
+131,,1,,,,,,,,
+132,,1,,,,,,,,
+133,,1,,,,,,,,
+134,,1,,,,,,,,
+135,,1,,,,,,,,
+136,,1,,,,,,,,
+137,,1,,,,,,,,
+138,,1,,,,,,,,
+139,,1,,,,,,,,
+140,,1,,,,,,,,
+141,,1,,,,,,,,
+142,,1,,,,,,,,
+143,,1,,,,,,,,
+144,,1,,,,,,,,
+145,,1,,,,,,,,
+146,,1,,,,,,,,
+147,,1,,,,,,,,
+148,,1,,,,,,,,
+149,,1,,,,,,,,
+150,,1,,,,,,,,
+151,,1,,,,,,,,
+152,,1,,,,,,,,
+153,,1,,,,,,,,
+154,,1,,,,,,,,
+155,,1,,,,,,,,
+156,,1,,,,,,,,
+157,,1,,,,,,,,
+158,,1,,,,,,,,
+159,,1,,,,,,,,
+160,,1,,,,,,,,
+161,,1,,,,,,,,
+162,,1,,,,,,,,
+163,,1,,,,,,,,
+164,,1,,,,,,,,
+165,,1,,,,,,,,
+166,,1,,,,,,,,
+167,,1,,,,,,,,
+168,,1,,,,,,,,
+169,,1,,,,,,,,
+170,,1,,,,,,,,
+171,,1,,,,,,,,
+172,,1,,,,,,,,
+173,,1,,,,,,,,
+174,,1,,,,,,,,
+175,,1,,,,,,,,
+176,,1,,,,,,,,
+177,,1,,,,,,,,
+178,,1,,,,,,,,
+179,,1,,,,,,,,
+1000,,1,,,,,,,,
+1001,,1,,,,,,,,
+1002,,1,,,,,,,,
+1003,,1,,,,,,,,
+1004,,1,,,,,,,,
+1005,,1,,,,,,,,
+1006,,1,,,,,,,,
+1007,,1,,,,,,,,
+1008,,1,,,,,,,,
+1009,,1,,,,,,,,
+1010,,1,,,,,,,,
+1011,,1,,,,,,,,
+1012,,1,,,,,,,,
+1013,,1,,,,,,,,
+1014,,1,,,,,,,,
+1015,,1,,,,,,,,
+1016,,1,,,,,,,,
+1017,,1,,,,,,,,
+1018,,1,,,,,,,,
+1019,,1,,,,,,,,
+1020,,1,,,,,,,,
+1021,,1,,,,,,,,
+1022,,1,,,,,,,,
+1023,,1,,,,,,,,
+1024,,1,,,,,,,,
+1025,,1,,,,,,,,
+1026,,1,,,,,,,,
+1027,,1,,,,,,,,
+1028,,1,,,,,,,,
+1029,,1,,,,,,,,
+1030,,1,,,,,,,,
+1031,,1,,,,,,,,
+1032,,1,,,,,,,,
+1033,,1,,,,,,,,
+1037,,1,,,,,,,,
+1038,,1,,,,,,,,
+1039,,1,,,,,,,,
+1040,,1,,,,,,,,
+1041,,1,,,,,,,,
+1042,,1,,,,,,,,
+1043,,1,,,,,,,,
+1044,,1,,,,,,,,
+1045,,1,,,,,,,,
+1046,,1,,,,,,,,
+1047,,1,,,,,,,,
+1048,,1,,,,,,,,
+1049,,1,,,,,,,,
+1050,,1,,,,,,,,
+1051,,1,,,,,,,,
+1052,,1,,,,,,,,
+1053,,1,,,,,,,,
+1054,,1,,,,,,,,
+1055,,1,,,,,,,,
+1056,,1,,,,,,,,
+1057,,1,,,,,,,,
+1058,,1,,,,,,,,
+1059,,1,,,,,,,,
+1060,,1,,,,,,,,
+1061,,1,,,,,,,,
+1062,,1,,,,,,,,
+1063,,1,,,,,,,,
+1065,,1,,,,,,,,
+1066,,1,,,,,,,,
+1067,,1,,,,,,,,
+1068,,1,,,,,,,,
+1069,,1,,,,,,,,
+1071,,1,,,,,,,,
+1073,,1,,,,,,,,
+1076,,1,,,,,,,,
+1077,,1,,,,,,,,
+1078,,1,,,,,,,,
+1079,,1,,,,,,,,
+1080,,1,,,,,,,,
+1081,,1,,,,,,,,
+1083,,1,,,,,,,,
+1084,,1,,,,,,,,
+1085,,1,,,,,,,,
+1087,,1,,,,,,,,
+1088,,1,,,,,,,,
+1089,,1,,,,,,,,
+1091,,1,,,,,,,,
+1092,,1,,,,,,,,
+1093,,1,,,,,,,,
+1094,,1,,,,,,,,
+1095,,1,,,,,,,,
+1096,,1,,,,,,,,
+1097,,1,,,,,,,,
+1098,,1,,,,,,,,
+1099,,1,,,,,,,,
+1100,,1,,,,,,,,
+1101,,1,,,,,,,,
+1102,,1,,,,,,,,
+1103,,1,,,,,,,,
+1104,,1,,,,,,,,
+1105,,1,,,,,,,,
diff --git a/scripts/surveys/survey_maker.py b/scripts/surveys/survey_maker.py
index 05c7cb4f..cf2d3ff8 100644
--- a/scripts/surveys/survey_maker.py
+++ b/scripts/surveys/survey_maker.py
@@ -8,23 +8,20 @@ API_PATH = "/tmp-api/v1"
 LOCAL_ITEMS_FOLDER = "../../frontend/static/surveys/items"
 REMOTE_ITEMS_FOLDER = "/surveys/items"
 
-# PARSE ITEMS
+# PARSE QCM QUESTIONS
 
-df_items = pd.read_csv("items.csv", dtype=str)
+df_questions_qcm = pd.read_csv("questions_qcm.csv", dtype=str)
 
-items = []
-for i, row in df_items.iterrows():
+questions_qcm = []
+for i, row in df_questions_qcm.iterrows():
     row = row.dropna()
     id_ = int(row["id"])
 
-    o = {"id": id_, "question": None, "correct": None}
-    items.append(o)
+    o = {"id": id_, "question": None, "question_qcm": {"correct": None}}
+    questions_qcm.append(o)
 
     if "question" in row:
-        if re.search(r"<.*?>", str(row["question"])):
-            o["question"] = f'gap:{row["question"]}'
-        else:
-            o["question"] = f'text:{row["question"]}'
+        o["question"] = f'text:{row["question"]}'
     elif os.path.isfile(f"{LOCAL_ITEMS_FOLDER}/{id_}/q.mp3"):
         o["question"] = f"audio:{REMOTE_ITEMS_FOLDER}/{id_}/q.mp3"
     elif os.path.isfile(f"{LOCAL_ITEMS_FOLDER}/{id_}/q.jpeg"):
@@ -33,7 +30,7 @@ for i, row in df_items.iterrows():
         print(f"Failed to find a question for item {id_}")
 
     if "correct" in row:
-        o["correct"] = int(row["correct"])
+        o["question_qcm"]["correct"] = int(row["correct"])
     else:
         print(f"Failed to find corect for item {id_}")
 
@@ -41,22 +38,22 @@ for i, row in df_items.iterrows():
         with open(f"{LOCAL_ITEMS_FOLDER}/{id_}/1_dropdown.txt", "r") as file:
             options = file.read().split(",")
         options = [option.strip() for option in options]
-        o[f"option1"] = f"dropdown:{', '.join(options)}"
+        o["question_qcm"][f"option1"] = f"dropdown:{', '.join(options)}"
     elif os.path.isfile(f"{LOCAL_ITEMS_FOLDER}/{id_}/1_radio.txt"):
         with open(f"{LOCAL_ITEMS_FOLDER}/{id_}/1_radio.txt", "r") as file:
             options = file.read().split(",")
         options = [option.strip() for option in options]
-        o[f"option1"] = f"radio:{', '.join(options)}"
+        o["question_qcm"][f"option1"] = f"radio:{', '.join(options)}"
     else:
         for j in range(1, 9):
             op = f"option{j}"
             if op in row:
-                o[op] = "text:" + row[op]
+                o["question_qcm"][op] = "text:" + row[op]
             elif os.path.isfile(f"{LOCAL_ITEMS_FOLDER}/{id_}/{j}.mp3"):
-                o[op] = f"audio:{REMOTE_ITEMS_FOLDER}/{id_}/{j}.mp3"
+                o["question_qcm"][op] = f"audio:{REMOTE_ITEMS_FOLDER}/{id_}/{j}.mp3"
             elif os.path.isfile(f"{LOCAL_ITEMS_FOLDER}/{id_}/{j}.jpeg"):
-                o[op] = f"image:{REMOTE_ITEMS_FOLDER}/{id_}/{j}.jpeg"
-    # print(o)
+                o["question_qcm"][op] = f"image:{REMOTE_ITEMS_FOLDER}/{id_}/{j}.jpeg"
+
 # PARSE GROUPS
 
 groups = []
@@ -73,8 +70,8 @@ with open("groups.csv") as file:
 
 # PARSE SURVEYS
 
-surveys = []
-with open("surveys.csv") as file:
+tests_task = []
+with open("tests_task.csv") as file:
     file.readline()
     for line in file.read().split("\n"):
         if not line:
@@ -82,7 +79,7 @@ with open("surveys.csv") as file:
         id_, title, *gps = line.split(",")
         id_ = int(id_)
         gps = [int(x) for x in gps if x]
-        surveys.append({"id": id_, "title": title, "groups_id": gps})
+        tests_task.append({"id": id_, "test_task": {"title": title}, "groups_id": gps})
 
 # SESSION DATA
 
@@ -99,22 +96,22 @@ assert (
     response_code == 200
 ), f"Probably wrong username or password. Status code: {response_code}"
 
-# CREATE ITEMS
+# CREATE QUESTIONS QCM
 
-n_items = 0
+n_questions_qcm = 0
 
-for item in items:
+for q in questions_qcm:
     assert session.delete(
-        f'{API_URL}{API_PATH}/surveys/items/{item["id"]}'
-    ).status_code in [404, 204], f'Failed to delete item {item["id"]}'
-    r = session.post(f"{API_URL}{API_PATH}/surveys/items", json=item)
+        f'{API_URL}{API_PATH}/tests/questions/{q["id"]}'
+    ).status_code in [404, 204], f'Failed to delete item {q["id"]}'
+    r = session.post(f"{API_URL}{API_PATH}/tests/questions", json=q)
     if r.status_code not in [201]:
-        print(f'Failed to create item {item["id"]}: {r.text}')
+        print(f'Failed to create item {q["id"]}: {r.text}')
         continue
     else:
-        n_items += 1
+        n_questions_qcm += 1
 else:
-    print(f"Successfully created {n_items}/{len(items)} items")
+    print(f"Successfully created {n_questions_qcm}/{len(questions_qcm)} qcm questions")
 
 # CREATE GROUPS
 
@@ -124,9 +121,9 @@ for group in groups:
     group = group.copy()
     its = group.pop("items_id")
     assert session.delete(
-        f'{API_URL}{API_PATH}/surveys/groups/{group["id"]}'
+        f'{API_URL}{API_PATH}/tests/groups/{group["id"]}'
     ).status_code in [404, 204], f'Failed to delete group {group["id"]}'
-    r = session.post(f"{API_URL}{API_PATH}/surveys/groups", json=group)
+    r = session.post(f"{API_URL}{API_PATH}/tests/groups", json=group)
     if r.status_code not in [201]:
         print(f'Failed to create group {group["id"]}: {r.text}')
         continue
@@ -135,14 +132,13 @@ for group in groups:
 
     for it in its:
         assert session.delete(
-            f'{API_URL}{API_PATH}/surveys/groups/{group["id"]}/items/{it}'
+            f'{API_URL}{API_PATH}/tests/groups/{group["id"]}/questions/{it}'
         ).status_code in [
             404,
             204,
-        ], f'Failed to delete item {it} from group {group["id"]}'
+        ], f'Failed to delete question {it} from group {group["id"]}'
         r = session.post(
-            f'{API_URL}{API_PATH}/surveys/groups/{group["id"]}/items',
-            json={"question_id": it},
+            f'{API_URL}{API_PATH}/surveys/groups/{group["id"]}/questions/{it}'
         )
         if r.status_code not in [201]:
             print(f'Failed to add item {it} to group {group["id"]}: {r.text}')
@@ -151,39 +147,35 @@ for group in groups:
 else:
     print(f"Successfully created {n_groups}/{len(groups)} groups")
 
-# CREATE SURVEYS
+# CREATE TASK TESTS
 
-n_surveys = 0
+n_task_tests = 0
 
-for survey in surveys:
-    survey = survey.copy()
-    gps = survey.pop("groups_id")
-    assert session.delete(
-        f'{API_URL}{API_PATH}/surveys/{survey["id"]}'
-    ).status_code in [
+for t in tests_task:
+    t = t.copy()
+    gps = t.pop("groups_id")
+    assert session.delete(f'{API_URL}{API_PATH}/tests/{t["id"]}').status_code in [
         404,
         204,
-    ], f'Failed to delete survey {survey["id"]}'
-    r = session.post(f"{API_URL}{API_PATH}/surveys", json=survey)
+    ], f'Failed to delete test {t["id"]}'
+    r = session.post(f"{API_URL}{API_PATH}/tests", json=t)
     if r.status_code not in [201]:
-        print(f'Failed to create suvey {survey["id"]}: {r.text}')
+        print(f'Failed to create suvey {t["id"]}: {r.text}')
         continue
     else:
-        n_surveys += 1
+        n_task_tests += 1
 
     for gp in gps:
         assert session.delete(
-            f'{API_URL}{API_PATH}/surveys/{survey["id"]}/groups/{gp}'
+            f'{API_URL}{API_PATH}/tests/{t["id"]}/groups/{gp}'
         ).status_code in [
             404,
             204,
-        ], f'Failed to delete gp {gp} from survey {survey["id"]}'
-        r = session.post(
-            f'{API_URL}{API_PATH}/surveys/{survey["id"]}/groups', json={"group_id": gp}
-        )
+        ], f'Failed to delete gp {gp} from test {t["id"]}'
+        r = session.post(f'{API_URL}{API_PATH}/tests/{t["id"]}/groups/{gp}')
         if r.status_code not in [201]:
-            print(f'Failed to add group {gp} to survey {survey["id"]}: {r.text}')
+            print(f'Failed to add group {gp} to test {t["id"]}: {r.text}')
             break
 
 else:
-    print(f"Successfully created {n_surveys}/{len(surveys)} surveys")
+    print(f"Successfully created {n_task_tests}/{len(tests_task)} tests")
diff --git a/scripts/surveys/surveys.csv b/scripts/surveys/tests_task.csv
similarity index 100%
rename from scripts/surveys/surveys.csv
rename to scripts/surveys/tests_task.csv
-- 
GitLab


From 195e1f7fd2cef852ca05900e8c999c9e33d7200e Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Tue, 25 Feb 2025 15:38:40 +0100
Subject: [PATCH 15/44] update study same logic as create study

---
 .../lib/components/studies/StudyForm.svelte   | 90 +++++++------------
 frontend/src/lib/types/study.svelte.ts        | 16 ++--
 .../routes/admin/studies/[id]/+page.server.ts | 86 ++++++++++++++++++
 .../routes/admin/studies/[id]/+page.svelte    | 11 +--
 .../src/routes/admin/studies/new/+page.svelte |  6 +-
 5 files changed, 133 insertions(+), 76 deletions(-)
 create mode 100644 frontend/src/routes/admin/studies/[id]/+page.server.ts

diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte
index 4204b0a8..0f8bb45b 100644
--- a/frontend/src/lib/components/studies/StudyForm.svelte
+++ b/frontend/src/lib/components/studies/StudyForm.svelte
@@ -2,7 +2,7 @@
 	import DateInput from '$lib/components/utils/dateInput.svelte';
 	import Draggable from './Draggable.svelte';
 	import autosize from 'svelte-autosize';
-	import { toastWarning, toastAlert, toastSuccess } from '$lib/utils/toasts';
+	import { toastWarning, toastAlert } from '$lib/utils/toasts';
 	import { getUserByEmailAPI } from '$lib/api/users';
 	import { Icon, MagnifyingGlass } from 'svelte-hero-icons';
 	import { t } from '$lib/services/i18n';
@@ -10,38 +10,37 @@
 	import User from '$lib/types/user';
 	import SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
 	import type Study from '$lib/types/study.svelte';
-	import { formatToUTCDate } from '$lib/utils/date';
 
 	let {
 		study = $bindable(),
 		possibleTests,
-		mode
+		mode,
+		data,
+		form
 	}: {
 		study: Study | null;
 		possibleTests: (Survey | SurveyTypingSvelte)[];
 		mode: string; //"create" or "edit"
+		data: any;
+		form: any;
 	} = $props();
 
 	let hasToLoggin: boolean = $state(false);
+	let title = study ? study.title : '';
+	let description = study ? study.description : '';
+	let startDate = study ? study.startDate : new Date();
+	let endDate = study ? study.endDate : new Date();
+	let chatDuration = study ? study.chatDuration : 30;
+	let tests = study ? [...study.tests] : [];
+	let consentParticipation = study ? study.consentParticipation : '';
+	let consentPrivacy = study ? study.consentPrivacy : '';
+	let consentRights = study ? study.consentRights : '';
+	let consentStudyData = study ? study.consentStudyData : '';
 
-	let title: string | null = $state(study?.title ?? null);
-	let description: string | null = $state(study?.description ?? null);
-	let startDate: Date = $state(study?.startDate ?? new Date());
-	let endDate: Date = $state(study?.endDate ?? new Date());
-	let chatDuration: number = $state(study?.chatDuration ?? 30);
-	let tests: (SurveyTypingSvelte | Survey)[] = $state(study?.tests ?? []);
-	let users: User[] = $state(study?.users ?? []);
-	let consentParticipation: string = $state(study?.consentParticipation ?? '');
-	let consentPrivacy: string = $state(study?.consentPrivacy ?? '');
-	let consentRights: string = $state(study?.consentRights ?? '');
-	let consentStudyData: string = $state(study?.consentStudyData ?? '');
 	let newUsername: string = $state('');
 	let newUserModal = $state(false);
 	let selectedTest: SurveyTypingSvelte | Survey | undefined = $state();
-
-	console.log(endDate);
-
-	$inspect(endDate);
+	let users: User[] = $state(study?.users ?? []);
 
 	async function addUser() {
 		newUserModal = true;
@@ -71,29 +70,6 @@
 		users = users.filter((u) => u.id !== user.id);
 	}
 
-	async function studyUpdate() {
-		if (!study) return;
-		const result = await study.patch({
-			title: title,
-			description: description,
-			start_date: formatToUTCDate(startDate),
-			end_date: formatToUTCDate(endDate),
-			chat_duration: chatDuration,
-			tests: tests,
-			consent_participation: consentParticipation,
-			consent_privacy: consentPrivacy,
-			consent_rights: consentRights,
-			consent_study_data: consentStudyData
-		});
-
-		if (result) {
-			toastSuccess($t('studies.updated'));
-		} else {
-			toastAlert($t('studies.updateError'));
-		}
-		window.location.href = '/admin/studies';
-	}
-
 	async function deleteStudy() {
 		if (!study) return;
 		await study?.delete();
@@ -105,6 +81,11 @@
 	<h2 class="text-xl font-bold m-5 text-center">
 		{$t(mode === 'create' ? 'studies.createTitle' : 'studies.editTitle')}
 	</h2>
+	{#if form?.message}
+		<div class="alert alert-error mb-4">
+			{form.message}
+		</div>
+	{/if}
 	<form method="post">
 		<!-- Title & description -->
 		<label class="label" for="title">{$t('utils.words.title')} *</label>
@@ -248,20 +229,15 @@
 			bind:value={consentStudyData}
 			required
 		></textarea>
-
-		{#if mode === 'create'}
-			<div class="mt-4 mb-6">
-				<button class="button">{$t('button.create')}</button>
-				<a class="btn btn-outline float-end ml-2" href="/admin/studies">
-					{$t('button.cancel')}
-				</a>
-			</div>
-		{:else}
-			<div class="mt-4 mb-6">
-				<button type="button" class="button" onclick={studyUpdate}>{$t('button.update')}</button>
-				<a class="btn btn-outline float-end ml-2" href="/admin/studies">
-					{$t('button.cancel')}
-				</a>
+		<div class="mt-4 mb-6">
+			<input type="hidden" name="studyId" value={study ? study.id : ''} />
+			<button type="submit" class="button">
+				{$t(mode === 'create' ? 'button.create' : 'button.update')}
+			</button>
+			<a class="btn btn-outline float-end ml-2" href="/admin/studies">
+				{$t('button.cancel')}
+			</a>
+			{#if mode === 'edit'}
 				<button
 					type="button"
 					class="btn btn-error btn-outline float-end"
@@ -269,8 +245,8 @@
 				>
 					{$t('button.delete')}
 				</button>
-			</div>
-		{/if}
+			{/if}
+		</div>
 	</form>
 </div>
 <dialog class="modal bg-black bg-opacity-50" open={newUserModal}>
diff --git a/frontend/src/lib/types/study.svelte.ts b/frontend/src/lib/types/study.svelte.ts
index 607cd8fc..82261027 100644
--- a/frontend/src/lib/types/study.svelte.ts
+++ b/frontend/src/lib/types/study.svelte.ts
@@ -14,17 +14,17 @@ import Survey from '$lib/types/survey';
 
 export default class Study {
 	private _id: number;
-	private _title: string = $state('');
-	private _description: string = $state('');
+	private _title: string;
+	private _description: string;
 	private _startDate: Date;
 	private _endDate: Date;
-	private _chatDuration: number = $state(0);
+	private _chatDuration: number;
 	private _users: User[];
-	private _consentParticipation: string = $state('');
-	private _consentPrivacy: string = $state('');
-	private _consentRights: string = $state('');
-	private _consentStudyData: string = $state('');
-	private _tests: (SurveyTypingSvelte | Survey)[] = $state([]);
+	private _consentParticipation: string;
+	private _consentPrivacy: string;
+	private _consentRights: string;
+	private _consentStudyData: string;
+	private _tests: (SurveyTypingSvelte | Survey)[];
 
 	private constructor(
 		id: number,
diff --git a/frontend/src/routes/admin/studies/[id]/+page.server.ts b/frontend/src/routes/admin/studies/[id]/+page.server.ts
new file mode 100644
index 00000000..df02ad2f
--- /dev/null
+++ b/frontend/src/routes/admin/studies/[id]/+page.server.ts
@@ -0,0 +1,86 @@
+import { patchStudyAPI } from '$lib/api/studies';
+import { redirect, type Actions } from '@sveltejs/kit';
+import { formatToUTCDate } from '$lib/utils/date';
+
+export const actions: Actions = {
+	default: async ({ request, fetch, params }) => {
+		const formData = await request.formData();
+		const studyId = params.id;
+
+		console.log('here');
+
+		if (!studyId) return { message: 'Invalid study ID' };
+
+		const title = formData.get('title')?.toString();
+		const description = formData.get('description')?.toString() || '';
+		const startDateStr = formData.get('startDate')?.toString();
+		const endDateStr = formData.get('endDate')?.toString();
+		const chatDurationStr = formData.get('chatDuration')?.toString();
+		const consentParticipation = formData.get('consentParticipation')?.toString();
+		const consentPrivacy = formData.get('consentPrivacy')?.toString();
+		const consentRights = formData.get('consentRights')?.toString();
+		const consentStudyData = formData.get('consentStudyData')?.toString();
+
+		console.log('here1');
+
+		if (
+			!title ||
+			!startDateStr ||
+			!endDateStr ||
+			!chatDurationStr ||
+			!consentParticipation ||
+			!consentPrivacy ||
+			!consentRights ||
+			!consentStudyData
+		) {
+			return { message: 'Invalid request' };
+		}
+
+		const startDate = new Date(startDateStr);
+		const endDate = new Date(endDateStr);
+		const chatDuration = parseInt(chatDurationStr, 10);
+
+		if (isNaN(startDate.getTime()) || isNaN(endDate.getTime()) || isNaN(chatDuration)) {
+			return { message: 'Invalid date or chat duration' };
+		}
+
+		if (startDate.getTime() > endDate.getTime()) {
+			return { message: 'End time cannot be before start time' };
+		}
+
+		const tests = formData
+			.getAll('tests')
+			.map((test) => {
+				try {
+					return JSON.parse(test.toString());
+				} catch (e) {
+					return null;
+				}
+			})
+			.filter((test) => test !== null);
+
+		console.log('here2');
+
+		const updated = await patchStudyAPI(fetch, parseInt(studyId, 10), {
+			title: title,
+			description: description,
+			start_date: formatToUTCDate(startDate),
+			end_date: formatToUTCDate(endDate),
+			chat_duration: chatDuration,
+			tests: tests,
+			consent_participation: consentParticipation,
+			consent_privacy: consentPrivacy,
+			consent_rights: consentRights,
+			consent_study_data: consentStudyData
+		});
+
+		console.log('here3');
+		console.log(updated);
+
+		if (!updated) return { message: 'Failed to update study' };
+
+		console.log('Action executed');
+		console.log('here4');
+		return redirect(303, '/admin/studies');
+	}
+};
diff --git a/frontend/src/routes/admin/studies/[id]/+page.svelte b/frontend/src/routes/admin/studies/[id]/+page.svelte
index fca64a47..33978c26 100644
--- a/frontend/src/routes/admin/studies/[id]/+page.svelte
+++ b/frontend/src/routes/admin/studies/[id]/+page.svelte
@@ -1,14 +1,9 @@
 <script lang="ts">
 	import StudyForm from '$lib/components/studies/StudyForm.svelte';
 	import SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
-	import type { PageData } from './$types';
-
-	let {
-		data
-	}: {
-		data: PageData;
-	} = $props();
+	import type { PageData, ActionData } from './$types';
 
+	let { data, form }: { data: PageData; form: ActionData } = $props();
 	let study = data.study;
 	let typing = $state(new SurveyTypingSvelte());
 
@@ -18,7 +13,7 @@
 </script>
 
 {#if study}
-	<StudyForm {study} {possibleTests} {mode} />
+	<StudyForm {study} {possibleTests} {mode} {data} {form} />
 {:else}
 	<p>StudySvelte not found.</p>
 {/if}
diff --git a/frontend/src/routes/admin/studies/new/+page.svelte b/frontend/src/routes/admin/studies/new/+page.svelte
index a28ea035..24547583 100644
--- a/frontend/src/routes/admin/studies/new/+page.svelte
+++ b/frontend/src/routes/admin/studies/new/+page.svelte
@@ -1,13 +1,13 @@
 <script lang="ts">
 	import StudyForm from '$lib/components/studies/StudyForm.svelte';
 	import SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
-	import type { PageData } from './$types';
+	import type { PageData, ActionData } from './$types';
 
-	let { data }: { data: PageData } = $props();
+	let { data, form }: { data: PageData; form: ActionData } = $props();
 	let study = null;
 	let typing = $state(new SurveyTypingSvelte());
 	let possibleTests = [typing, ...data.surveys];
 	let mode = 'create';
 </script>
 
-<StudyForm {study} {possibleTests} {mode} />
+<StudyForm {study} {possibleTests} {mode} {data} {form} />
-- 
GitLab


From e18b07362801f11b37d8491eeb0dd4d5a477c898 Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Tue, 25 Feb 2025 16:00:54 +0100
Subject: [PATCH 16/44] session duration replaced by number of sessions

---
 frontend/src/lang/fr.json                     |  1 +
 frontend/src/lib/api/studies.ts               |  4 +--
 .../lib/components/studies/StudyForm.svelte   | 10 +++---
 frontend/src/lib/types/study.svelte.ts        | 34 ++++++-------------
 .../routes/admin/studies/new/+page.server.ts  | 10 +++---
 5 files changed, 24 insertions(+), 35 deletions(-)

diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json
index 2d0aed6b..ca33c724 100644
--- a/frontend/src/lang/fr.json
+++ b/frontend/src/lang/fr.json
@@ -346,6 +346,7 @@
 		"startDate": "Date de début",
 		"endDate": "Date de fin",
 		"chatDuration": "Durée des sessions (en minutes)",
+		"nbSession": "Nombre de sessions",
 		"updated": "Étude mise à jour avec succès",
 		"noChanges": "Aucune modification",
 		"updateError": "Erreur lors de la mise à jour de l'étude",
diff --git a/frontend/src/lib/api/studies.ts b/frontend/src/lib/api/studies.ts
index d70b2eab..f1722d69 100644
--- a/frontend/src/lib/api/studies.ts
+++ b/frontend/src/lib/api/studies.ts
@@ -26,7 +26,7 @@ export async function createStudyAPI(
 	description: string,
 	startDate: Date,
 	endDate: Date,
-	chatDuration: number,
+	nbSession: number,
 	tests: { type: string; id?: number }[],
 	consentParticipation: string,
 	consentPrivacy: string,
@@ -41,7 +41,7 @@ export async function createStudyAPI(
 			description,
 			start_date: formatToUTCDate(startDate),
 			end_date: formatToUTCDate(endDate),
-			chat_duration: chatDuration,
+			nb_session: nbSession,
 			tests,
 			consent_participation: consentParticipation,
 			consent_privacy: consentPrivacy,
diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte
index 0f8bb45b..de7e8434 100644
--- a/frontend/src/lib/components/studies/StudyForm.svelte
+++ b/frontend/src/lib/components/studies/StudyForm.svelte
@@ -30,7 +30,7 @@
 	let description = study ? study.description : '';
 	let startDate = study ? study.startDate : new Date();
 	let endDate = study ? study.endDate : new Date();
-	let chatDuration = study ? study.chatDuration : 30;
+	let nbSession = study ? study.nbSession : 8;
 	let tests = study ? [...study.tests] : [];
 	let consentParticipation = study ? study.consentParticipation : '';
 	let consentPrivacy = study ? study.consentPrivacy : '';
@@ -106,14 +106,14 @@
 		<DateInput class="input w-full" id="endDate" name="endDate" date={endDate} required />
 
 		<!-- Chat Duration -->
-		<label class="label" for="chatDuration">{$t('studies.chatDuration')} *</label>
+		<label class="label" for="nbSession">{$t('studies.nbSession')} *</label>
 		<input
 			class="input w-full"
 			type="number"
-			id="chatDuration"
-			name="chatDuration"
+			id="nbSession"
+			name="nbSession"
 			min="0"
-			bind:value={chatDuration}
+			bind:value={nbSession}
 			required
 		/>
 
diff --git a/frontend/src/lib/types/study.svelte.ts b/frontend/src/lib/types/study.svelte.ts
index 82261027..a28ba68e 100644
--- a/frontend/src/lib/types/study.svelte.ts
+++ b/frontend/src/lib/types/study.svelte.ts
@@ -18,7 +18,7 @@ export default class Study {
 	private _description: string;
 	private _startDate: Date;
 	private _endDate: Date;
-	private _chatDuration: number;
+	private _nbSession: number = $state(0);
 	private _users: User[];
 	private _consentParticipation: string;
 	private _consentPrivacy: string;
@@ -32,7 +32,7 @@ export default class Study {
 		description: string,
 		startDate: Date,
 		endDate: Date,
-		chatDuration: number,
+		nbSession: number,
 		users: User[],
 		consentParticipation: string,
 		consentPrivacy: string,
@@ -45,7 +45,7 @@ export default class Study {
 		this._description = description;
 		this._startDate = startDate;
 		this._endDate = endDate;
-		this._chatDuration = chatDuration;
+		this._nbSession = nbSession;
 		this._users = users;
 		this._consentParticipation = consentParticipation;
 		this._consentPrivacy = consentPrivacy;
@@ -90,12 +90,12 @@ export default class Study {
 		this._endDate = value;
 	}
 
-	get chatDuration(): number {
-		return this._chatDuration;
+	get nbSession(): number {
+		return this._nbSession;
 	}
 
-	set chatDuration(value: number) {
-		this._chatDuration = value;
+	set nbSession(value: number) {
+		this._nbSession = value;
 	}
 
 	get users(): User[] {
@@ -155,7 +155,7 @@ export default class Study {
 		description: string,
 		startDate: Date,
 		endDate: Date,
-		chatDuration: number,
+		nbSession: number,
 		consentParticipation: string,
 		consentPrivacy: string,
 		consentRights: string,
@@ -163,19 +163,7 @@ export default class Study {
 		tests: (SurveyTypingSvelte | Survey)[],
 		f: fetchType = fetch
 	): Promise<Study | null> {
-		const id = await createStudyAPI(
-			f,
-			title,
-			description,
-			startDate,
-			endDate,
-			chatDuration,
-			[],
-			consentParticipation,
-			consentPrivacy,
-			consentRights,
-			consentStudyData
-		);
+		const id = await createStudyAPI(f, title, description, startDate, endDate, nbSession, []);
 
 		if (id) {
 			return new Study(
@@ -184,7 +172,7 @@ export default class Study {
 				description,
 				startDate,
 				endDate,
-				chatDuration,
+				nbSession,
 				[],
 				consentParticipation,
 				consentPrivacy,
@@ -207,7 +195,7 @@ export default class Study {
 			if (data.description) this._description = data.description;
 			if (data.start_date) this._startDate = parseToLocalDate(data.start_date);
 			if (data.end_date) this._endDate = parseToLocalDate(data.end_date);
-			if (data.chat_duration) this._chatDuration = data.chat_duration;
+			if (data.chat_duration) this._nbSession = data.chat_duration;
 			if (data.consent_participation) this._consentParticipation = data.consent_participation;
 			if (data.consent_privacy) this._consentPrivacy = data.consent_privacy;
 			if (data.consent_rights) this._consentRights = data.consent_rights;
diff --git a/frontend/src/routes/admin/studies/new/+page.server.ts b/frontend/src/routes/admin/studies/new/+page.server.ts
index d26d02e0..3d265202 100644
--- a/frontend/src/routes/admin/studies/new/+page.server.ts
+++ b/frontend/src/routes/admin/studies/new/+page.server.ts
@@ -9,7 +9,7 @@ export const actions: Actions = {
 		let description = formData.get('description')?.toString();
 		const startDateStr = formData.get('startDate')?.toString();
 		const endDateStr = formData.get('endDate')?.toString();
-		const chatDurationStr = formData.get('chatDuration')?.toString();
+		const nbSessionStr = formData.get('nbSession')?.toString();
 
 		const consentParticipation = formData.get('consentParticipation')?.toString();
 		const consentPrivacy = formData.get('consentPrivacy')?.toString();
@@ -20,7 +20,7 @@ export const actions: Actions = {
 			!title ||
 			!startDateStr ||
 			!endDateStr ||
-			!chatDurationStr ||
+			!nbSessionStr ||
 			!consentParticipation ||
 			!consentPrivacy ||
 			!consentRights ||
@@ -44,8 +44,8 @@ export const actions: Actions = {
 			};
 		}
 
-		const chatDuration = parseInt(chatDurationStr, 10);
-		if (isNaN(chatDuration)) {
+		const nbSession = parseInt(nbSessionStr, 10);
+		if (isNaN(nbSession)) {
 			return {
 				message: 'Invalid request 3'
 			};
@@ -74,7 +74,7 @@ export const actions: Actions = {
 			description,
 			startDate,
 			endDate,
-			chatDuration,
+			nbSession,
 			tests,
 			consentParticipation,
 			consentPrivacy,
-- 
GitLab


From 90e6e9e3845a27a033d3e633f5b9f58166df3205 Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Tue, 25 Feb 2025 16:08:07 +0100
Subject: [PATCH 17/44] put study back in .ts as .svelte was only needed for
 $state

---
 .../src/lib/components/studies/StudyForm.svelte  |  2 +-
 .../src/lib/types/{study.svelte.ts => study.ts}  | 16 ++++++++++++++--
 frontend/src/routes/admin/studies/+page.svelte   |  2 +-
 frontend/src/routes/admin/studies/+page.ts       |  2 +-
 frontend/src/routes/admin/studies/[id]/+page.ts  |  2 +-
 .../src/routes/register/[[studyId]]/+page.svelte |  2 +-
 6 files changed, 19 insertions(+), 7 deletions(-)
 rename frontend/src/lib/types/{study.svelte.ts => study.ts} (96%)

diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte
index de7e8434..57b7efdf 100644
--- a/frontend/src/lib/components/studies/StudyForm.svelte
+++ b/frontend/src/lib/components/studies/StudyForm.svelte
@@ -9,7 +9,7 @@
 	import Survey from '$lib/types/survey';
 	import User from '$lib/types/user';
 	import SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
-	import type Study from '$lib/types/study.svelte';
+	import type Study from '$lib/types/study.js';
 
 	let {
 		study = $bindable(),
diff --git a/frontend/src/lib/types/study.svelte.ts b/frontend/src/lib/types/study.ts
similarity index 96%
rename from frontend/src/lib/types/study.svelte.ts
rename to frontend/src/lib/types/study.ts
index a28ba68e..903b1fc7 100644
--- a/frontend/src/lib/types/study.svelte.ts
+++ b/frontend/src/lib/types/study.ts
@@ -18,7 +18,7 @@ export default class Study {
 	private _description: string;
 	private _startDate: Date;
 	private _endDate: Date;
-	private _nbSession: number = $state(0);
+	private _nbSession: number;
 	private _users: User[];
 	private _consentParticipation: string;
 	private _consentPrivacy: string;
@@ -163,7 +163,19 @@ export default class Study {
 		tests: (SurveyTypingSvelte | Survey)[],
 		f: fetchType = fetch
 	): Promise<Study | null> {
-		const id = await createStudyAPI(f, title, description, startDate, endDate, nbSession, []);
+		const id = await createStudyAPI(
+			f,
+			title,
+			description,
+			startDate,
+			endDate,
+			nbSession,
+			[],
+			consentParticipation,
+			consentPrivacy,
+			consentRights,
+			consentStudyData
+		);
 
 		if (id) {
 			return new Study(
diff --git a/frontend/src/routes/admin/studies/+page.svelte b/frontend/src/routes/admin/studies/+page.svelte
index b8decf0e..e9bcd1b5 100644
--- a/frontend/src/routes/admin/studies/+page.svelte
+++ b/frontend/src/routes/admin/studies/+page.svelte
@@ -1,6 +1,6 @@
 <script lang="ts">
 	import { t } from '$lib/services/i18n';
-	import Study from '$lib/types/study.svelte';
+	import Study from '$lib/types/study.js';
 	import { displayDate } from '$lib/utils/date';
 	import type { PageData } from './$types';
 
diff --git a/frontend/src/routes/admin/studies/+page.ts b/frontend/src/routes/admin/studies/+page.ts
index 27b821e8..9d25fe3c 100644
--- a/frontend/src/routes/admin/studies/+page.ts
+++ b/frontend/src/routes/admin/studies/+page.ts
@@ -1,5 +1,5 @@
 import { getStudiesAPI } from '$lib/api/studies';
-import Study from '$lib/types/study.svelte';
+import Study from '$lib/types/study.js';
 import { type Load } from '@sveltejs/kit';
 
 export const load: Load = async ({ fetch }) => {
diff --git a/frontend/src/routes/admin/studies/[id]/+page.ts b/frontend/src/routes/admin/studies/[id]/+page.ts
index 85b3b8c4..ed4290ef 100644
--- a/frontend/src/routes/admin/studies/[id]/+page.ts
+++ b/frontend/src/routes/admin/studies/[id]/+page.ts
@@ -1,7 +1,7 @@
 import { getSurveysAPI } from '$lib/api/survey';
 import Survey from '$lib/types/survey';
 import { type Load, redirect } from '@sveltejs/kit';
-import Study from '$lib/types/study.svelte';
+import Study from '$lib/types/study.js';
 import { getStudyAPI } from '$lib/api/studies';
 
 export const load: Load = async ({ fetch, params }) => {
diff --git a/frontend/src/routes/register/[[studyId]]/+page.svelte b/frontend/src/routes/register/[[studyId]]/+page.svelte
index 6f8f6650..171c6e78 100644
--- a/frontend/src/routes/register/[[studyId]]/+page.svelte
+++ b/frontend/src/routes/register/[[studyId]]/+page.svelte
@@ -8,7 +8,7 @@
 	import { browser } from '$app/environment';
 	import type { PageData } from './$types';
 	import Consent from '$lib/components/surveys/consent.svelte';
-	import type Study from '$lib/types/study.svelte';
+	import type Study from '$lib/types/study';
 
 	let { data, form }: { data: PageData; form: FormData } = $props();
 	let study: Study | undefined = $state(data.study);
-- 
GitLab


From f1ee239463b34cfc495af64d61933595a760a6ef Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Tue, 25 Feb 2025 18:06:54 +0100
Subject: [PATCH 18/44] docs for StudyForm.svelte and study.ts

---
 .../lib/components/studies/StudyForm.svelte   | 27 ++++++++++--
 frontend/src/lib/types/study.ts               | 44 +++++++++++++++++++
 2 files changed, 68 insertions(+), 3 deletions(-)

diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte
index 57b7efdf..44e11692 100644
--- a/frontend/src/lib/components/studies/StudyForm.svelte
+++ b/frontend/src/lib/components/studies/StudyForm.svelte
@@ -42,10 +42,19 @@
 	let selectedTest: SurveyTypingSvelte | Survey | undefined = $state();
 	let users: User[] = $state(study?.users ?? []);
 
-	async function addUser() {
+	/**
+	 * Opens the participant search dialog to allow adding a new user.
+	 */
+	function addUser() {
 		newUserModal = true;
 	}
 
+	/**
+	 * Add the new participant to the user list if the email given is properly formated and is found
+	 * in the database.
+	 * If successful close the dialog to search participant.
+	 * @async
+	 */
 	async function searchUser() {
 		if (!newUsername || !newUsername.includes('@')) {
 			toastWarning($t('studies.invalidEmail'));
@@ -66,10 +75,18 @@
 		newUserModal = false;
 	}
 
-	async function removeUser(user: User) {
+	/**
+	 * Remove the user from the list of users.
+	 * @param user the user to be removed
+	 */
+	function removeUser(user: User) {
 		users = users.filter((u) => u.id !== user.id);
 	}
 
+	/**
+	 * Deletes the current study from the database and redirects to the admin studies page.
+	 * @async
+	 */
 	async function deleteStudy() {
 		if (!study) return;
 		await study?.delete();
@@ -81,6 +98,7 @@
 	<h2 class="text-xl font-bold m-5 text-center">
 		{$t(mode === 'create' ? 'studies.createTitle' : 'studies.editTitle')}
 	</h2>
+	<!-- if error message to display -->
 	{#if form?.message}
 		<div class="alert alert-error mb-4">
 			{form.message}
@@ -105,7 +123,7 @@
 		<label class="label" for="endDate">{$t('studies.endDate')} *</label>
 		<DateInput class="input w-full" id="endDate" name="endDate" date={endDate} required />
 
-		<!-- Chat Duration -->
+		<!-- number of Sessions -->
 		<label class="label" for="nbSession">{$t('studies.nbSession')} *</label>
 		<input
 			class="input w-full"
@@ -229,6 +247,8 @@
 			bind:value={consentStudyData}
 			required
 		></textarea>
+
+		<!-- submit, cancel and delete buttons -->
 		<div class="mt-4 mb-6">
 			<input type="hidden" name="studyId" value={study ? study.id : ''} />
 			<button type="submit" class="button">
@@ -249,6 +269,7 @@
 		</div>
 	</form>
 </div>
+<!-- Dialog for the research of participant to be added -->
 <dialog class="modal bg-black bg-opacity-50" open={newUserModal}>
 	<div class="modal-box">
 		<h2 class="text-xl font-bold mb-4">{$t('studies.newUser')}</h2>
diff --git a/frontend/src/lib/types/study.ts b/frontend/src/lib/types/study.ts
index 903b1fc7..40f2dffd 100644
--- a/frontend/src/lib/types/study.ts
+++ b/frontend/src/lib/types/study.ts
@@ -150,6 +150,11 @@ export default class Study {
 		this._tests = value;
 	}
 
+	/**
+	 * Creates a new Study and saves it in the database.
+	 * @async
+	 * @returns a Study instance if successful, null otherwise
+	 */
 	static async create(
 		title: string,
 		description: string,
@@ -196,10 +201,21 @@ export default class Study {
 		return null;
 	}
 
+	/**
+	 * Delete this study from the database
+	 * @async
+	 */
 	async delete(f: fetchType = fetch): Promise<void> {
 		await deleteStudyAPI(f, this._id);
 	}
 
+	/**
+	 * Update the study in the database with the new data.
+	 * @async
+	 * @param data  the updated fields
+	 * @param f
+	 * @return `true` if successful, `false` otherwise
+	 */
 	async patch(data: any, f: fetchType = fetch): Promise<boolean> {
 		const res = await patchStudyAPI(f, this._id, data);
 		if (res) {
@@ -218,6 +234,14 @@ export default class Study {
 		return false;
 	}
 
+	/**
+	 * Remove the given user from this study in the database, if successful update the user list of
+	 * this study.
+	 * @async
+	 * @param user the user to remove
+	 * @param f
+	 * @return `true` if successful, `false` otherwise
+	 */
 	async removeUser(user: User, f: fetchType = fetch): Promise<boolean> {
 		const res = await removeUserToStudyAPI(f, this._id, user.id);
 		if (res) {
@@ -227,6 +251,14 @@ export default class Study {
 		return res;
 	}
 
+	/**
+	 * Add the given user from this study in the database, if successful update the user list of this
+	 * study.
+	 * @async
+	 * @param user the user to be added
+	 * @param f
+	 * @return `true` if successful, `false` otherwise
+	 */
 	async addUser(user: User, f: fetchType = fetch): Promise<boolean> {
 		const res = await addUserToStudyAPI(f, this._id, user.id);
 		if (res) {
@@ -236,6 +268,12 @@ export default class Study {
 		return res;
 	}
 
+	/**
+	 * Parses a JSON object into a Study instance.
+	 *
+	 * @param json the JSON object representing a study.
+	 * @returns a Study instance if successful, `null` otherwise
+	 */
 	static parse(json: any): Study | null {
 		if (json === null || json === undefined) {
 			toastAlert('Failed to parse study: json is null');
@@ -262,6 +300,12 @@ export default class Study {
 		return study;
 	}
 
+	/**
+	 * Parses an array of json into an array of Study instances.
+	 *
+	 * @param json an array of json representing studies
+	 * @returns an array of Study instances. If parsing fails, returns an empty array.
+	 */
 	static parseAll(json: any): Study[] {
 		if (json === null || json === undefined) {
 			toastAlert('Failed to parse studies: json is null');
-- 
GitLab


From 072bc0380148e42a1bdaa1cc84b6016298977750 Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Thu, 27 Feb 2025 13:34:26 +0100
Subject: [PATCH 19/44] placeholder for the consent part of the form

---
 frontend/src/lang/fr.json                     |  6 ++++-
 .../lib/components/studies/StudyForm.svelte   | 25 ++++++++++++++++---
 2 files changed, 26 insertions(+), 5 deletions(-)

diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json
index ca33c724..91889c13 100644
--- a/frontend/src/lang/fr.json
+++ b/frontend/src/lang/fr.json
@@ -373,7 +373,11 @@
 		"typingTestDuration": "Durée (en secondes)",
 		"typingTestRepetition": "Nombre de fois à répéter",
 		"typingTestText": "Texte",
-		"typingTestInfoNote": "Si aucune durée n'est fournis le mode \"plus vite que possible\" sera activé."
+		"typingTestInfoNote": "Si aucune durée n'est fournis le mode \"plus vite que possible\" sera activé.",
+		"consentParticipation": "Si vous acceptez de participer, vous serez invité·e à participer à des sessions de tutorat en ligne avec un tuteur de langue étrangère. Vous serez également invité à remplir des questionnaires avant et après les sessions de tutorat. Les sessions de tutorat seront enregistrées pour analyse ultérieure.Nous vous demandons de prévoir de réaliser un minimum de 8 sessions d'une heure de tutorat (donc 8 heures au total), au cours d'une période de 1 à 3 mois. Vous pouvez bien sûr en réaliser plus si vous le souhaitez. Vous pouvez cependant arrêter de participer à l'étude à tout moment.",
+		"consentPrivacy": "Les données collectées (par exemple, les transcriptions des conversations, les résultats de tests, les mesures de frappe, les informations sur les participants comme l'age ou le genre) seront traitées de manière confidentielle et anonyme. Elles seront conservées après leur anonymisation intégrale et ne pourront être utilisées qu'à des fins scientifiques ou pédagogiques. Elles pourront éventuellement être partagées avec d'autres chercheurs ou enseignants, mais toujours dans ce cadre strictement de recherche ou d'enseignement.",
+		"consentRights": "Votre participation à cette étude est volontaire. Vous pouvez à tout moment décider de ne plus participer à l'étude sans avoir à vous justifier. Vous pouvez également demander à ce que vos données soient supprimées à tout moment. Si vous avez des questions ou des préoccupations concernant cette étude, vous pouvez contacter le responsable de l'étude.",
+		"consentStudyData": "Informations sur l'étude."
 	},
 	"button": {
 		"create": "Créer",
diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte
index 44e11692..f54dea79 100644
--- a/frontend/src/lib/components/studies/StudyForm.svelte
+++ b/frontend/src/lib/components/studies/StudyForm.svelte
@@ -10,6 +10,7 @@
 	import User from '$lib/types/user';
 	import SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
 	import type Study from '$lib/types/study.js';
+	import { onMount } from 'svelte';
 
 	let {
 		study = $bindable(),
@@ -32,16 +33,32 @@
 	let endDate = study ? study.endDate : new Date();
 	let nbSession = study ? study.nbSession : 8;
 	let tests = study ? [...study.tests] : [];
-	let consentParticipation = study ? study.consentParticipation : '';
-	let consentPrivacy = study ? study.consentPrivacy : '';
-	let consentRights = study ? study.consentRights : '';
-	let consentStudyData = study ? study.consentStudyData : '';
+	let consentParticipation = study
+		? study.consentParticipation
+		: $t('studies.consentParticipation');
+	let consentPrivacy = study ? study.consentPrivacy : $t('studies.consentPrivacy');
+	let consentRights = study ? study.consentRights : $t('studies.consentRights');
+	let consentStudyData = study ? study.consentStudyData : $t('studies.consentStudyData');
 
 	let newUsername: string = $state('');
 	let newUserModal = $state(false);
 	let selectedTest: SurveyTypingSvelte | Survey | undefined = $state();
 	let users: User[] = $state(study?.users ?? []);
 
+	/**
+	 * Triggers the autosize update for all textarea elements in the document, to adjust the textarea
+	 * size based on the initial content.
+	 */
+	function triggerAutosize() {
+		const textareas = document.querySelectorAll('textarea');
+		textareas.forEach((textarea) => {
+			autosize.update(textarea);
+		});
+	}
+	onMount(() => {
+		triggerAutosize();
+	});
+
 	/**
 	 * Opens the participant search dialog to allow adding a new user.
 	 */
-- 
GitLab


From 857cf3e0ff0489b7153d631ac0b24a66a0072411 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Thu, 27 Feb 2025 17:35:23 +0100
Subject: [PATCH 20/44] Fix creation of studies

---
 backend/app/crud/tests.py                     | 18 ++--
 backend/app/main.py                           |  2 +
 backend/app/models/studies.py                 |  2 +-
 backend/app/models/tests.py                   | 28 ++++--
 backend/app/routes/studies.py                 | 54 +++++++++++
 backend/app/routes/tests.py                   |  2 +-
 backend/app/schemas/studies.py                |  6 +-
 backend/app/schemas/tests.py                  | 36 ++++++--
 frontend/src/lib/api/studies.ts               |  2 +-
 frontend/src/lib/api/survey.ts                | 10 +--
 .../lib/components/studies/StudyForm.svelte   | 11 ++-
 frontend/src/lib/types/testTaskGroups.ts      | 52 +++++++++++
 frontend/src/lib/types/testTaskQuestions.ts   | 83 +++++++++++++++++
 frontend/src/lib/types/tests.ts               | 89 +++++++++++++++++++
 .../routes/admin/studies/new/+page.server.ts  |  8 +-
 .../src/routes/admin/studies/new/+page.svelte |  4 +-
 .../src/routes/admin/studies/new/+page.ts     |  6 +-
 scripts/surveys/survey_maker.py               | 67 ++++++++++++--
 18 files changed, 429 insertions(+), 51 deletions(-)
 create mode 100644 backend/app/routes/studies.py
 create mode 100644 frontend/src/lib/types/testTaskGroups.ts
 create mode 100644 frontend/src/lib/types/testTaskQuestions.ts
 create mode 100644 frontend/src/lib/types/tests.ts

diff --git a/backend/app/crud/tests.py b/backend/app/crud/tests.py
index e8b5ea20..4ae42314 100644
--- a/backend/app/crud/tests.py
+++ b/backend/app/crud/tests.py
@@ -34,9 +34,12 @@ def add_group_to_test_task(db: Session, test: models.Test, group: models.TestTas
 def remove_group_from_test_task(
     db: Session, testTask: models.TestTask, group: models.TestTaskGroup
 ):
-    testTask.groups.remove(group)
-    db.commit()
-    db.refresh(testTask)
+    try:
+        testTask.groups.remove(group)
+        db.commit()
+        db.refresh(testTask)
+    except ValueError:
+        pass
 
 
 def create_group(
@@ -73,9 +76,12 @@ def add_question_to_group(
 def remove_question_from_group(
     db: Session, group: models.TestTaskGroup, question: models.TestTaskQuestion
 ):
-    group.questions.remove(question)
-    db.commit()
-    db.refresh(group)
+    try:
+        group.questions.remove(question)
+        db.commit()
+        db.refresh(group)
+    except ValueError:
+        pass
 
 
 def create_question(db: Session, question: schemas.TestTaskQuestionCreate):
diff --git a/backend/app/main.py b/backend/app/main.py
index 8a3539e2..ac9c7b29 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -27,6 +27,7 @@ from utils import check_user_level
 import config
 from security import jwt_cookie, get_jwt_user
 from routes.tests import testRouter
+from routes.studies import studiesRouter
 
 websocket_users = defaultdict(lambda: defaultdict(set))
 websocket_users_global = defaultdict(set)
@@ -1055,5 +1056,6 @@ v1Router.include_router(sessionsRouter)
 v1Router.include_router(studyRouter)
 v1Router.include_router(websocketRouter)
 v1Router.include_router(testRouter)
+v1Router.include_router(studiesRouter)
 apiRouter.include_router(v1Router)
 app.include_router(apiRouter)
diff --git a/backend/app/models/studies.py b/backend/app/models/studies.py
index f817514b..fd0db73b 100644
--- a/backend/app/models/studies.py
+++ b/backend/app/models/studies.py
@@ -17,7 +17,7 @@ class Study(Base):
     description = Column(String)
     start_date = Column(DateTime)
     end_date = Column(DateTime)
-    week_duration = Column(Integer)
+    nb_session = Column(Integer)
     consent_participation = Column(String)
     consent_privacy = Column(String)
     consent_rights = Column(String)
diff --git a/backend/app/models/tests.py b/backend/app/models/tests.py
index 2041a7a0..3d2b9e6c 100644
--- a/backend/app/models/tests.py
+++ b/backend/app/models/tests.py
@@ -1,4 +1,4 @@
-from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, String
+from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String
 from sqlalchemy.orm import relationship, validates
 from utils import datetime_aware
 from database import Base
@@ -43,7 +43,11 @@ class TestTask(Base):
     test = relationship(
         "Test", uselist=False, back_populates="test_task", lazy="selectin"
     )
-    groups = relationship("TestTaskGroup", secondary="test_task_task_groups")
+    groups = relationship(
+        "TestTaskGroup",
+        secondary="test_task_task_groups",
+        lazy="selectin",
+    )
 
 
 class Test(Base):
@@ -52,10 +56,16 @@ class Test(Base):
     id = Column(Integer, primary_key=True, index=True)
 
     test_typing = relationship(
-        "TestTyping", uselist=False, back_populates="test", lazy="selectin"
+        "TestTyping",
+        uselist=False,
+        back_populates="test",
+        lazy="selectin",
     )
     test_task = relationship(
-        "TestTask", uselist=False, back_populates="test", lazy="selectin"
+        "TestTask",
+        uselist=False,
+        back_populates="test",
+        lazy="selectin",
     )
 
     @validates("test_typing")
@@ -79,13 +89,14 @@ class TestTaskTaskGroup(Base):
 class TestTaskGroup(Base):
     __tablename__ = "test_task_groups"
     id = Column(Integer, primary_key=True, index=True)
-    test_task_id = Column(Integer, ForeignKey("test_tasks.test_id"), index=True)
+
     title = Column(String, nullable=False)
-    demo = Column(String, default=False)
+    demo = Column(Boolean, default=False)
 
     questions = relationship(
         "TestTaskQuestion",
         secondary="test_task_group_questions",
+        lazy="selectin",
     )
 
 
@@ -123,7 +134,10 @@ class TestTaskQuestion(Base):
     question = Column(String, nullable=True)
 
     question_qcm = relationship(
-        "TestTaskQuestionQCM", uselist=False, back_populates="question"
+        "TestTaskQuestionQCM",
+        uselist=False,
+        back_populates="question",
+        lazy="selectin",
     )
 
     @validates("question_qcm")
diff --git a/backend/app/routes/studies.py b/backend/app/routes/studies.py
new file mode 100644
index 00000000..ea6b7ae9
--- /dev/null
+++ b/backend/app/routes/studies.py
@@ -0,0 +1,54 @@
+from fastapi import APIRouter, Depends, status
+
+import crud
+import schemas
+from database import get_db
+from models import Session
+from routes.decorators import require_admin
+
+
+studiesRouter = APIRouter(prefix="/studies", tags=["Studies"])
+
+
+@require_admin("You do not have permission to create a study.")
+@studiesRouter.post("", status_code=status.HTTP_201_CREATED)
+def create_study(
+    study: schemas.StudyCreate,
+    db: Session = Depends(get_db),
+):
+    return crud.create_study(db, study).id
+
+
+@studiesRouter.get("", response_model=list[schemas.Study])
+def get_studies(
+    skip: int = 0,
+    db: Session = Depends(get_db),
+):
+    return crud.get_studies(db, skip)
+
+
+@studiesRouter.get("/{study_id}", response_model=schemas.Study)
+def get_study(
+    study_id: int,
+    db: Session = Depends(get_db),
+):
+    return crud.get_study(db, study_id)
+
+
+@require_admin("You do not have permission to patch a study.")
+@studiesRouter.patch("/{study_id}", status_code=status.HTTP_204_NO_CONTENT)
+def update_study(
+    study_id: int,
+    study: schemas.StudyCreate,
+    db: Session = Depends(get_db),
+):
+    return crud.update_study(db, study, study_id)
+
+
+@require_admin("You do not have permission to delete a study.")
+@studiesRouter.delete("/{study_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_study(
+    study_id: int,
+    db: Session = Depends(get_db),
+):
+    return crud.delete_study(db, study_id)
diff --git a/backend/app/routes/tests.py b/backend/app/routes/tests.py
index fab26033..bb23b3a1 100644
--- a/backend/app/routes/tests.py
+++ b/backend/app/routes/tests.py
@@ -34,7 +34,7 @@ def get_test(
     return crud.get_test(db, test_id)
 
 
-@require_admin("You do not have permission to delete a test.")
+@require_admin("You do not have permission to: delete a test.")
 @testRouter.delete("/{test_id}", status_code=status.HTTP_204_NO_CONTENT)
 def delete_test(
     test_id: int,
diff --git a/backend/app/schemas/studies.py b/backend/app/schemas/studies.py
index 81c47734..f5d2aac8 100644
--- a/backend/app/schemas/studies.py
+++ b/backend/app/schemas/studies.py
@@ -9,14 +9,14 @@ class StudyCreate(BaseModel):
     description: str
     start_date: NaiveDatetime
     end_date: NaiveDatetime
-    week_duration: int = 8
+    nb_session: int = 8
     consent_participation: str
     consent_privacy: str
     consent_rights: str
     consent_study_data: str
 
-    users: list[User]
-    tests: list[Test]
+    users: list[User] = []
+    tests: list[Test] = []
 
 
 class Study(StudyCreate):
diff --git a/backend/app/schemas/tests.py b/backend/app/schemas/tests.py
index 6f694145..e2c1ba92 100644
--- a/backend/app/schemas/tests.py
+++ b/backend/app/schemas/tests.py
@@ -30,6 +30,8 @@ class TestCreate(BaseModel):
 
 
 class TestTaskGroupCreate(BaseModel):
+    # TODO remove
+    id: int | None = None
     title: str
     demo: bool = False
 
@@ -64,8 +66,25 @@ class TestTaskGroupAdd(BaseModel):
     task_id: int
 
 
-class TestTaskQuestionQCM(TestTaskQuestionQCMCreate):
-    pass
+class TestTaskQuestionQCM(BaseModel):
+    correct: int
+    options: list[str]
+
+    model_config = {"from_attributes": True}
+
+    @model_validator(mode="before")
+    @classmethod
+    def extract_options(cls, data):
+        if hasattr(data, "__dict__"):
+            result = {"correct": getattr(data, "correct", None), "options": []}
+
+            for i in range(1, 9):
+                option_value = getattr(data, f"option{i}", None)
+                if option_value is not None and option_value != "":
+                    result["options"].append(option_value)
+
+            return result
+        return data
 
 
 class TestTaskQuestion(BaseModel):
@@ -75,23 +94,22 @@ class TestTaskQuestion(BaseModel):
 
 
 class TestTaskGroup(TestTaskGroupCreate):
-    id: int
+    # id: int
     questions: list[TestTaskQuestion] = []
 
 
 class TestTask(TestTaskCreate):
-    id: int
     groups: list[TestTaskGroup] = []
 
 
 class TestTyping(TestTypingCreate):
-    id: int
+    pass
 
 
-class Test(TestCreate):
-    # TODO add
-    # id: int
-    pass
+class Test(BaseModel):
+    id: int
+    test_typing: TestTyping | None = None
+    test_task: TestTask | None = None
 
 
 class TestTaskEntryQCMCreate(BaseModel):
diff --git a/frontend/src/lib/api/studies.ts b/frontend/src/lib/api/studies.ts
index f1722d69..82eea784 100644
--- a/frontend/src/lib/api/studies.ts
+++ b/frontend/src/lib/api/studies.ts
@@ -42,7 +42,7 @@ export async function createStudyAPI(
 			start_date: formatToUTCDate(startDate),
 			end_date: formatToUTCDate(endDate),
 			nb_session: nbSession,
-			tests,
+			//tests,
 			consent_participation: consentParticipation,
 			consent_privacy: consentPrivacy,
 			consent_rights: consentRights,
diff --git a/frontend/src/lib/api/survey.ts b/frontend/src/lib/api/survey.ts
index ea693447..cebdca29 100644
--- a/frontend/src/lib/api/survey.ts
+++ b/frontend/src/lib/api/survey.ts
@@ -1,13 +1,13 @@
 import type { fetchType } from '$lib/utils/types';
 
 export async function getSurveysAPI(fetch: fetchType) {
-	const response = await fetch('/api/surveys');
+	const response = await fetch('/api/tests');
 	if (!response.ok) return null;
 	return await response.json();
 }
 
 export async function getSurveyAPI(fetch: fetchType, survey_id: number) {
-	const response = await fetch(`/api/surveys/${survey_id}`);
+	const response = await fetch(`/api/tests/${survey_id}`);
 	if (!response.ok) return null;
 
 	return await response.json();
@@ -25,7 +25,7 @@ export async function sendSurveyResponseAPI(
 	response_time: number,
 	text: string = ''
 ) {
-	const response = await fetch(`/api/surveys/responses`, {
+	const response = await fetch(`/api/tests/responses`, {
 		method: 'POST',
 		headers: { 'Content-Type': 'application/json' },
 		body: JSON.stringify({
@@ -45,7 +45,7 @@ export async function sendSurveyResponseAPI(
 }
 
 export async function getSurveyScoreAPI(fetch: fetchType, survey_id: number, sid: string) {
-	const response = await fetch(`/api/surveys/${survey_id}/score/${sid}`);
+	const response = await fetch(`/api/tests/${survey_id}/score/${sid}`);
 	if (!response.ok) return null;
 
 	return await response.json();
@@ -61,7 +61,7 @@ export async function sendSurveyResponseInfoAPI(
 	other_language: string,
 	education: string
 ) {
-	const response = await fetch(`/api/surveys/info/${survey_id}`, {
+	const response = await fetch(`/api/tests/info/${survey_id}`, {
 		method: 'POST',
 		headers: { 'Content-Type': 'application/json' },
 		body: JSON.stringify({
diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte
index f54dea79..14201917 100644
--- a/frontend/src/lib/components/studies/StudyForm.svelte
+++ b/frontend/src/lib/components/studies/StudyForm.svelte
@@ -11,6 +11,7 @@
 	import SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
 	import type Study from '$lib/types/study.js';
 	import { onMount } from 'svelte';
+	import { TestTask, type Test } from '$lib/types/tests';
 
 	let {
 		study = $bindable(),
@@ -20,7 +21,7 @@
 		form
 	}: {
 		study: Study | null;
-		possibleTests: (Survey | SurveyTypingSvelte)[];
+		possibleTests: Test[];
 		mode: string; //"create" or "edit"
 		data: any;
 		form: any;
@@ -158,10 +159,12 @@
 		<div class="flex">
 			<select class="select select-bordered flex-grow" bind:value={selectedTest}>
 				{#each possibleTests as test}
-					{#if test instanceof Survey}
-						<option value={test}>{test.title}</option>
+					{#if test instanceof TestTask}
+						<option value={test}
+							>{test.title} - {test.groups.length} groups - {test.numQuestions} questions</option
+						>
 					{:else}
-						<option value={test}>{test.name}</option>
+						<!-- <option value={test}>{test.name}</option> -->
 					{/if}
 				{/each}
 			</select>
diff --git a/frontend/src/lib/types/testTaskGroups.ts b/frontend/src/lib/types/testTaskGroups.ts
new file mode 100644
index 00000000..74c89e16
--- /dev/null
+++ b/frontend/src/lib/types/testTaskGroups.ts
@@ -0,0 +1,52 @@
+import { toastAlert } from '$lib/utils/toasts';
+import { TestTaskQuestion } from './testTaskQuestions';
+
+export default class TestTaskGroup {
+	private _id: number;
+	private _title: string;
+	private _demo: boolean;
+	private _questions: TestTaskQuestion[];
+
+	constructor(id: number, title: string, demo: boolean, questions: TestTaskQuestion[]) {
+		this._id = id;
+		this._title = title;
+		this._demo = demo;
+		this._questions = questions;
+	}
+
+	get id(): number {
+		return this._id;
+	}
+
+	get title(): string {
+		return this._title;
+	}
+
+	get demo(): boolean {
+		return this._demo;
+	}
+
+	get questions(): TestTaskQuestion[] {
+		return this._questions;
+	}
+
+	static parse(data: any): TestTaskGroup | null {
+		if (data === null) {
+			toastAlert('Failed to parse test task group data');
+			return null;
+		}
+		const questions = TestTaskQuestion.parseAll(data.questions);
+		return new TestTaskGroup(data.id, data.title, data.demo, questions);
+	}
+
+	static parseAll(data: any): TestTaskGroup[] {
+		if (data === null) {
+			toastAlert('Failed to parse test task group data');
+			return [];
+		}
+
+		return data
+			.map((group: any) => TestTaskGroup.parse(group))
+			.filter((group: TestTaskGroup | null): group is TestTaskGroup => group !== null);
+	}
+}
diff --git a/frontend/src/lib/types/testTaskQuestions.ts b/frontend/src/lib/types/testTaskQuestions.ts
new file mode 100644
index 00000000..d5ce0802
--- /dev/null
+++ b/frontend/src/lib/types/testTaskQuestions.ts
@@ -0,0 +1,83 @@
+export abstract class TestTaskQuestion {
+	private _id: number;
+	private _question: string;
+
+	constructor(id: number, question: string) {
+		this._id = id;
+		this._question = question;
+	}
+
+	get id(): number {
+		return this._id;
+	}
+
+	get question(): string {
+		return this._question;
+	}
+
+	static parse(data: any): TestTaskQuestion | null {
+		if (data === null) {
+			return null;
+		}
+
+		if (data.question_qcm) {
+			return TestTaskQuestionQcm.parse(data);
+		}
+
+		return null;
+	}
+
+	static parseAll(data: any): TestTaskQuestion[] {
+		if (data === null) {
+			return [];
+		}
+		return data
+			.map((question: any) => TestTaskQuestion.parse(question))
+			.filter(
+				(question: TestTaskQuestion | null): question is TestTaskQuestion => question !== null
+			);
+	}
+}
+
+export class TestTaskQuestionQcm extends TestTaskQuestion {
+	private _options: string[];
+	private _correct: number;
+
+	constructor(id: number, question: string, options: string[], correct: number) {
+		super(id, question);
+		this._options = options;
+		this._correct = correct;
+	}
+
+	get options(): string[] {
+		return this._options;
+	}
+
+	get correct(): number {
+		return this._correct;
+	}
+
+	static parse(data: any): TestTaskQuestionQcm | null {
+		if (data === null) {
+			return null;
+		}
+
+		return new TestTaskQuestionQcm(
+			data.id,
+			data.question,
+			data.question_qcm.options,
+			data.question_qcm.correct
+		);
+	}
+
+	static parseAll(data: any): TestTaskQuestionQcm[] {
+		if (data === null) {
+			return [];
+		}
+		return data
+			.map((question: any) => TestTaskQuestionQcm.parse(question))
+			.filter(
+				(question: TestTaskQuestionQcm | null): question is TestTaskQuestionQcm => question !== null
+			);
+	}
+}
diff --git a/frontend/src/lib/types/tests.ts b/frontend/src/lib/types/tests.ts
new file mode 100644
index 00000000..ba781d44
--- /dev/null
+++ b/frontend/src/lib/types/tests.ts
@@ -0,0 +1,89 @@
+import { toastAlert } from '$lib/utils/toasts';
+import TestTaskGroup from './testTaskGroups';
+
+export abstract class Test {
+	private _id: number;
+
+	constructor(id: number) {
+		this._id = id;
+	}
+
+	get id(): number {
+		return this._id;
+	}
+
+	static parse(data: any): Test | null {
+		if (data === null) {
+			toastAlert('Failed to parse test data');
+			return null;
+		}
+
+		if (data.test_task) {
+			return TestTask.parse(data);
+		}
+
+		if (data.test_typing) {
+			return TestTyping.parse(data);
+		}
+
+		return null;
+	}
+
+	static parseAll(data: any): Test[] {
+		if (data === null) {
+			toastAlert('Failed to parse test data');
+			return [];
+		}
+
+		return data
+			.map((test: any) => Test.parse(test))
+			.filter((test: Test | null): test is Test => test !== null);
+	}
+}
+
+export class TestTask extends Test {
+	private _title: string;
+	private _groups: TestTaskGroup[];
+
+	constructor(id: number, title: string, groups: TestTaskGroup[]) {
+		super(id);
+		this._title = title;
+		this._groups = groups;
+	}
+
+	get title(): string {
+		return this._title;
+	}
+
+	get groups(): TestTaskGroup[] {
+		return this._groups;
+	}
+
+	get numQuestions(): number {
+		return this._groups.reduce((acc, group) => acc + group.questions.length, 0);
+	}
+
+	static parse(data: any): TestTask | null {
+		if (data === null) {
+			toastAlert('Failed to parse test data');
+			return null;
+		}
+
+		const groups = TestTaskGroup.parseAll(data.test_task.groups);
+
+		return new TestTask(data.id, data.test_task.title, groups);
+	}
+
+	static parseAll(data: any): TestTask[] {
+		if (data === null) {
+			toastAlert('Failed to parse test data');
+			return [];
+		}
+
+		return data
+			.map((test: any) => TestTask.parse(test))
+			.filter((test: TestTask | null): test is TestTask => test !== null);
+	}
+}
+
+export class TestTyping extends Test {}
diff --git a/frontend/src/routes/admin/studies/new/+page.server.ts b/frontend/src/routes/admin/studies/new/+page.server.ts
index 3d265202..6abdbfb7 100644
--- a/frontend/src/routes/admin/studies/new/+page.server.ts
+++ b/frontend/src/routes/admin/studies/new/+page.server.ts
@@ -68,7 +68,7 @@ export const actions: Actions = {
 			})
 			.filter((test) => test !== null);
 
-		await createStudyAPI(
+		const id = await createStudyAPI(
 			fetch,
 			title,
 			description,
@@ -82,6 +82,12 @@ export const actions: Actions = {
 			consentStudyData
 		);
 
+		if (id === null) {
+			return {
+				message: 'Failed to create study'
+			};
+		}
+
 		return redirect(303, '/admin/studies');
 	}
 };
diff --git a/frontend/src/routes/admin/studies/new/+page.svelte b/frontend/src/routes/admin/studies/new/+page.svelte
index 24547583..c77221cf 100644
--- a/frontend/src/routes/admin/studies/new/+page.svelte
+++ b/frontend/src/routes/admin/studies/new/+page.svelte
@@ -1,12 +1,10 @@
 <script lang="ts">
 	import StudyForm from '$lib/components/studies/StudyForm.svelte';
-	import SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
 	import type { PageData, ActionData } from './$types';
 
 	let { data, form }: { data: PageData; form: ActionData } = $props();
 	let study = null;
-	let typing = $state(new SurveyTypingSvelte());
-	let possibleTests = [typing, ...data.surveys];
+	let possibleTests = data.tests;
 	let mode = 'create';
 </script>
 
diff --git a/frontend/src/routes/admin/studies/new/+page.ts b/frontend/src/routes/admin/studies/new/+page.ts
index 5512da25..741a66fc 100644
--- a/frontend/src/routes/admin/studies/new/+page.ts
+++ b/frontend/src/routes/admin/studies/new/+page.ts
@@ -1,11 +1,11 @@
 import { getSurveysAPI } from '$lib/api/survey';
-import Survey from '$lib/types/survey';
+import { Test } from '$lib/types/tests';
 import { type Load } from '@sveltejs/kit';
 
 export const load: Load = async ({ fetch }) => {
-	const surveys = Survey.parseAll(await getSurveysAPI(fetch));
+	const tests = Test.parseAll(await getSurveysAPI(fetch));
 
 	return {
-		surveys
+		tests
 	};
 };
diff --git a/scripts/surveys/survey_maker.py b/scripts/surveys/survey_maker.py
index cf2d3ff8..bca92b48 100644
--- a/scripts/surveys/survey_maker.py
+++ b/scripts/surveys/survey_maker.py
@@ -54,6 +54,28 @@ for i, row in df_questions_qcm.iterrows():
             elif os.path.isfile(f"{LOCAL_ITEMS_FOLDER}/{id_}/{j}.jpeg"):
                 o["question_qcm"][op] = f"image:{REMOTE_ITEMS_FOLDER}/{id_}/{j}.jpeg"
 
+# PARSE GAPFILL QUESTIONS
+
+df_questions_gapfill = pd.read_csv("questions_gapfill.csv", dtype=str)
+
+questions_gapfill = []
+for i, row in df_questions_gapfill.iterrows():
+    row = row.dropna()
+    id_ = int(row["id"])
+
+    o = {"id": id_, "question": None}
+    questions_gapfill.append(o)
+
+    if "question" in row:
+        o["question"] = f'text:{row["question"]}'
+    elif os.path.isfile(f"{LOCAL_ITEMS_FOLDER}/{id_}/q.mp3"):
+        o["question"] = f"audio:{REMOTE_ITEMS_FOLDER}/{id_}/q.mp3"
+    elif os.path.isfile(f"{LOCAL_ITEMS_FOLDER}/{id_}/q.jpeg"):
+        o["question"] = f"image:{REMOTE_ITEMS_FOLDER}/{id_}/q.jpeg"
+    else:
+        print(f"Failed to find a question for item {id_}")
+
+
 # PARSE GROUPS
 
 groups = []
@@ -108,11 +130,36 @@ for q in questions_qcm:
     if r.status_code not in [201]:
         print(f'Failed to create item {q["id"]}: {r.text}')
         continue
-    else:
-        n_questions_qcm += 1
+
+    if r.text != str(q["id"]):
+        print(f'Item {q["id"]} was created with id {r.text}')
+
+    n_questions_qcm += 1
 else:
     print(f"Successfully created {n_questions_qcm}/{len(questions_qcm)} qcm questions")
 
+# CREATE QUESTIONS GAPFILL
+
+n_questions_gapfill = 0
+
+for q in questions_gapfill:
+    assert session.delete(
+        f'{API_URL}{API_PATH}/tests/questions/{q["id"]}'
+    ).status_code in [404, 204], f'Failed to delete item {q["id"]}'
+    r = session.post(f"{API_URL}{API_PATH}/tests/questions", json=q)
+    if r.status_code not in [201]:
+        print(f'Failed to create item {q["id"]}: {r.text}')
+        continue
+
+    if r.text != str(q["id"]):
+        print(f'Item {q["id"]} was created with id {r.text}')
+
+    n_questions_gapfill += 1
+else:
+    print(
+        f"Successfully created {n_questions_gapfill}/{len(questions_gapfill)} gapfill questions"
+    )
+
 # CREATE GROUPS
 
 n_groups = 0
@@ -127,8 +174,11 @@ for group in groups:
     if r.status_code not in [201]:
         print(f'Failed to create group {group["id"]}: {r.text}')
         continue
-    else:
-        n_groups += 1
+
+    if r.text != str(group["id"]):
+        print(f'Group {group["id"]} was created with id {r.text}')
+
+    n_groups += 1
 
     for it in its:
         assert session.delete(
@@ -138,7 +188,7 @@ for group in groups:
             204,
         ], f'Failed to delete question {it} from group {group["id"]}'
         r = session.post(
-            f'{API_URL}{API_PATH}/surveys/groups/{group["id"]}/questions/{it}'
+            f'{API_URL}{API_PATH}/tests/groups/{group["id"]}/questions/{it}'
         )
         if r.status_code not in [201]:
             print(f'Failed to add item {it} to group {group["id"]}: {r.text}')
@@ -162,8 +212,11 @@ for t in tests_task:
     if r.status_code not in [201]:
         print(f'Failed to create suvey {t["id"]}: {r.text}')
         continue
-    else:
-        n_task_tests += 1
+
+    if r.text != str(t["id"]):
+        print(f'Survey {t["id"]} was created with id {r.text}')
+
+    n_task_tests += 1
 
     for gp in gps:
         assert session.delete(
-- 
GitLab


From 35ef00b9f0ac64d0d079569658d93494632e33b5 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Thu, 27 Feb 2025 18:18:54 +0100
Subject: [PATCH 21/44] Fix edition of studies

---
 backend/app/crud/studies.py                   | 14 ++++++++++-
 .../lib/components/studies/StudyForm.svelte   | 23 +++++++++++--------
 frontend/src/lib/types/study.ts               |  4 ++--
 .../routes/admin/studies/[id]/+page.server.ts | 23 +++++--------------
 .../routes/admin/studies/[id]/+page.svelte    |  6 ++---
 .../src/routes/admin/studies/[id]/+page.ts    |  8 +++----
 6 files changed, 40 insertions(+), 38 deletions(-)

diff --git a/backend/app/crud/studies.py b/backend/app/crud/studies.py
index 9d73af8d..7c70858b 100644
--- a/backend/app/crud/studies.py
+++ b/backend/app/crud/studies.py
@@ -23,8 +23,20 @@ def get_studies(db: Session, skip: int = 0) -> list[models.Study]:
 
 def update_study(db: Session, study: schemas.StudyCreate, study_id: int) -> None:
     db.query(models.Study).filter(models.Study.id == study_id).update(
-        {**study.model_dump(exclude_unset=True)}
+        {**study.model_dump(exclude_unset=True, exclude={"users", "tests"})}
     )
+
+    if study.model_fields_set & {"users", "tests"}:
+        if study_obj := db.query(models.Study).get(study_id):
+            if "users" in study.model_fields_set:
+                study_obj.users = [
+                    models.User(**user.model_dump()) for user in study.users
+                ]
+            if "tests" in study.model_fields_set:
+                study_obj.tests = [
+                    models.Test(**test.model_dump()) for test in study.tests
+                ]
+
     db.commit()
 
 
diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte
index 14201917..c37adb7c 100644
--- a/frontend/src/lib/components/studies/StudyForm.svelte
+++ b/frontend/src/lib/components/studies/StudyForm.svelte
@@ -34,12 +34,15 @@
 	let endDate = study ? study.endDate : new Date();
 	let nbSession = study ? study.nbSession : 8;
 	let tests = study ? [...study.tests] : [];
-	let consentParticipation = study
-		? study.consentParticipation
-		: $t('studies.consentParticipation');
-	let consentPrivacy = study ? study.consentPrivacy : $t('studies.consentPrivacy');
-	let consentRights = study ? study.consentRights : $t('studies.consentRights');
-	let consentStudyData = study ? study.consentStudyData : $t('studies.consentStudyData');
+	let consentParticipation =
+		form?.consentParticipation ??
+		(study ? study.consentParticipation : $t('studies.consentParticipation'));
+	let consentPrivacy =
+		form?.consentPrivacy ?? (study ? study.consentPrivacy : $t('studies.consentPrivacy'));
+	let consentRights =
+		form?.consentRights ?? (study ? study.consentRights : $t('studies.consentRights'));
+	let consentStudyData =
+		form?.consentStudyData ?? (study ? study.consentStudyData : $t('studies.consentStudyData'));
 
 	let newUsername: string = $state('');
 	let newUserModal = $state(false);
@@ -235,7 +238,7 @@
 			class="input w-full max-h-52"
 			id="consentParticipation"
 			name="consentParticipation"
-			bind:value={consentParticipation}
+			value={consentParticipation}
 			required
 		></textarea>
 		<label class="label text-sm" for="consentPrivacy">{$t('register.consent.privacy')} *</label>
@@ -244,7 +247,7 @@
 			class="input w-full max-h-52"
 			id="consentPrivacy"
 			name="consentPrivacy"
-			bind:value={consentPrivacy}
+			value={consentPrivacy}
 			required
 		></textarea>
 		<label class="label text-sm" for="consentRights">{$t('register.consent.rights')} *</label>
@@ -253,7 +256,7 @@
 			class="input w-full max-h-52"
 			id="consentRights"
 			name="consentRights"
-			bind:value={consentRights}
+			value={consentRights}
 			required
 		></textarea>
 		<label class="label text-sm" for="consentStudyData"
@@ -264,7 +267,7 @@
 			class="input w-full max-h-52"
 			id="consentStudyData"
 			name="consentStudyData"
-			bind:value={consentStudyData}
+			value={consentStudyData}
 			required
 		></textarea>
 
diff --git a/frontend/src/lib/types/study.ts b/frontend/src/lib/types/study.ts
index 40f2dffd..3a135c17 100644
--- a/frontend/src/lib/types/study.ts
+++ b/frontend/src/lib/types/study.ts
@@ -286,8 +286,8 @@ export default class Study {
 			json.description,
 			parseToLocalDate(json.start_date),
 			parseToLocalDate(json.end_date),
-			json.chat_duration,
-			[],
+			json.nb_session,
+			json.tests || [],
 			json.consent_participation,
 			json.consent_privacy,
 			json.consent_rights,
diff --git a/frontend/src/routes/admin/studies/[id]/+page.server.ts b/frontend/src/routes/admin/studies/[id]/+page.server.ts
index df02ad2f..5a741900 100644
--- a/frontend/src/routes/admin/studies/[id]/+page.server.ts
+++ b/frontend/src/routes/admin/studies/[id]/+page.server.ts
@@ -7,27 +7,23 @@ export const actions: Actions = {
 		const formData = await request.formData();
 		const studyId = params.id;
 
-		console.log('here');
-
 		if (!studyId) return { message: 'Invalid study ID' };
 
 		const title = formData.get('title')?.toString();
 		const description = formData.get('description')?.toString() || '';
 		const startDateStr = formData.get('startDate')?.toString();
 		const endDateStr = formData.get('endDate')?.toString();
-		const chatDurationStr = formData.get('chatDuration')?.toString();
+		const nbSessionStr = formData.get('nbSession')?.toString();
 		const consentParticipation = formData.get('consentParticipation')?.toString();
 		const consentPrivacy = formData.get('consentPrivacy')?.toString();
 		const consentRights = formData.get('consentRights')?.toString();
 		const consentStudyData = formData.get('consentStudyData')?.toString();
 
-		console.log('here1');
-
 		if (
 			!title ||
 			!startDateStr ||
 			!endDateStr ||
-			!chatDurationStr ||
+			!nbSessionStr ||
 			!consentParticipation ||
 			!consentPrivacy ||
 			!consentRights ||
@@ -38,10 +34,10 @@ export const actions: Actions = {
 
 		const startDate = new Date(startDateStr);
 		const endDate = new Date(endDateStr);
-		const chatDuration = parseInt(chatDurationStr, 10);
+		const nbSession = parseInt(nbSessionStr, 10);
 
-		if (isNaN(startDate.getTime()) || isNaN(endDate.getTime()) || isNaN(chatDuration)) {
-			return { message: 'Invalid date or chat duration' };
+		if (isNaN(startDate.getTime()) || isNaN(endDate.getTime()) || isNaN(nbSession)) {
+			return { message: 'Invalid date or session amount' };
 		}
 
 		if (startDate.getTime() > endDate.getTime()) {
@@ -59,14 +55,12 @@ export const actions: Actions = {
 			})
 			.filter((test) => test !== null);
 
-		console.log('here2');
-
 		const updated = await patchStudyAPI(fetch, parseInt(studyId, 10), {
 			title: title,
 			description: description,
 			start_date: formatToUTCDate(startDate),
 			end_date: formatToUTCDate(endDate),
-			chat_duration: chatDuration,
+			nb_session: nbSession,
 			tests: tests,
 			consent_participation: consentParticipation,
 			consent_privacy: consentPrivacy,
@@ -74,13 +68,8 @@ export const actions: Actions = {
 			consent_study_data: consentStudyData
 		});
 
-		console.log('here3');
-		console.log(updated);
-
 		if (!updated) return { message: 'Failed to update study' };
 
-		console.log('Action executed');
-		console.log('here4');
 		return redirect(303, '/admin/studies');
 	}
 };
diff --git a/frontend/src/routes/admin/studies/[id]/+page.svelte b/frontend/src/routes/admin/studies/[id]/+page.svelte
index 33978c26..82f06423 100644
--- a/frontend/src/routes/admin/studies/[id]/+page.svelte
+++ b/frontend/src/routes/admin/studies/[id]/+page.svelte
@@ -1,13 +1,11 @@
 <script lang="ts">
 	import StudyForm from '$lib/components/studies/StudyForm.svelte';
-	import SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
 	import type { PageData, ActionData } from './$types';
 
 	let { data, form }: { data: PageData; form: ActionData } = $props();
 	let study = data.study;
-	let typing = $state(new SurveyTypingSvelte());
 
-	let possibleTests = [typing, ...data.surveys];
+	let possibleTests = data.tests;
 
 	let mode = 'edit';
 </script>
@@ -15,5 +13,5 @@
 {#if study}
 	<StudyForm {study} {possibleTests} {mode} {data} {form} />
 {:else}
-	<p>StudySvelte not found.</p>
+	<p>Study not found.</p>
 {/if}
diff --git a/frontend/src/routes/admin/studies/[id]/+page.ts b/frontend/src/routes/admin/studies/[id]/+page.ts
index ed4290ef..ba7b272d 100644
--- a/frontend/src/routes/admin/studies/[id]/+page.ts
+++ b/frontend/src/routes/admin/studies/[id]/+page.ts
@@ -1,12 +1,10 @@
 import { getSurveysAPI } from '$lib/api/survey';
-import Survey from '$lib/types/survey';
 import { type Load, redirect } from '@sveltejs/kit';
 import Study from '$lib/types/study.js';
 import { getStudyAPI } from '$lib/api/studies';
+import { Test } from '$lib/types/tests';
 
 export const load: Load = async ({ fetch, params }) => {
-	const surveys = Survey.parseAll(await getSurveysAPI(fetch));
-
 	const id = Number(params.id);
 
 	const study = Study.parse(await getStudyAPI(fetch, id));
@@ -15,8 +13,10 @@ export const load: Load = async ({ fetch, params }) => {
 		redirect(303, '/admin/studies');
 	}
 
+	const tests = Test.parseAll(await getSurveysAPI(fetch));
+
 	return {
-		surveys,
+		tests,
 		study
 	};
 };
-- 
GitLab


From 1b1d4d9ec41990f1f5610558b4036145ace3dad3 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Thu, 27 Feb 2025 18:39:13 +0100
Subject: [PATCH 22/44] Add users to studies

---
 backend/app/crud/studies.py                   | 33 ++++++++++++-------
 backend/app/schemas/studies.py                | 18 ++++++++--
 .../lib/components/studies/StudyForm.svelte   |  5 ++-
 .../routes/admin/studies/[id]/+page.server.ts | 11 ++++---
 4 files changed, 48 insertions(+), 19 deletions(-)

diff --git a/backend/app/crud/studies.py b/backend/app/crud/studies.py
index 7c70858b..53bbe783 100644
--- a/backend/app/crud/studies.py
+++ b/backend/app/crud/studies.py
@@ -23,19 +23,30 @@ def get_studies(db: Session, skip: int = 0) -> list[models.Study]:
 
 def update_study(db: Session, study: schemas.StudyCreate, study_id: int) -> None:
     db.query(models.Study).filter(models.Study.id == study_id).update(
-        {**study.model_dump(exclude_unset=True, exclude={"users", "tests"})}
+        {**study.model_dump(exclude_unset=True, exclude={"user_ids", "test_ids"})}
     )
 
-    if study.model_fields_set & {"users", "tests"}:
-        if study_obj := db.query(models.Study).get(study_id):
-            if "users" in study.model_fields_set:
-                study_obj.users = [
-                    models.User(**user.model_dump()) for user in study.users
-                ]
-            if "tests" in study.model_fields_set:
-                study_obj.tests = [
-                    models.Test(**test.model_dump()) for test in study.tests
-                ]
+    print(study.model_fields_set)
+
+    if study.model_fields_set & {"user_ids", "test_ids"}:
+        if (
+            study_obj := db.query(models.Study)
+            .filter(models.Study.id == study_id)
+            .first()
+        ):
+            if "user_ids" in study.model_fields_set:
+                print(study_obj.users, study.user_ids)
+                study_obj.users = (
+                    db.query(models.User)
+                    .filter(models.User.id.in_(study.user_ids))
+                    .all()
+                )
+            if "test_ids" in study.model_fields_set:
+                study_obj.tests = (
+                    db.query(models.Test)
+                    .filter(models.Test.id.in_(study.test_ids))
+                    .all()
+                )
 
     db.commit()
 
diff --git a/backend/app/schemas/studies.py b/backend/app/schemas/studies.py
index f5d2aac8..5f22ab24 100644
--- a/backend/app/schemas/studies.py
+++ b/backend/app/schemas/studies.py
@@ -15,9 +15,21 @@ class StudyCreate(BaseModel):
     consent_rights: str
     consent_study_data: str
 
-    users: list[User] = []
-    tests: list[Test] = []
+    user_ids: list[int] = []
+    test_ids: list[int] = []
 
 
-class Study(StudyCreate):
+class Study(BaseModel):
     id: int
+    title: str
+    description: str
+    start_date: NaiveDatetime
+    end_date: NaiveDatetime
+    nb_session: int = 8
+    consent_participation: str
+    consent_privacy: str
+    consent_rights: str
+    consent_study_data: str
+
+    users: list[User] = []
+    tests: list[Test] = []
diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte
index c37adb7c..09307ebc 100644
--- a/frontend/src/lib/components/studies/StudyForm.svelte
+++ b/frontend/src/lib/components/studies/StudyForm.svelte
@@ -209,7 +209,10 @@
 				{#each users ?? [] as user (user.id)}
 					<tr>
 						<td>{$t('users.type.' + user.type)}</td>
-						<td>{user.nickname}</td>
+						<td>
+							{user.nickname}
+							<input type="hidden" name="users[]" value={user.id} />
+						</td>
 						<td>{user.email}</td>
 						<td>
 							<button
diff --git a/frontend/src/routes/admin/studies/[id]/+page.server.ts b/frontend/src/routes/admin/studies/[id]/+page.server.ts
index 5a741900..389eb799 100644
--- a/frontend/src/routes/admin/studies/[id]/+page.server.ts
+++ b/frontend/src/routes/admin/studies/[id]/+page.server.ts
@@ -44,8 +44,8 @@ export const actions: Actions = {
 			return { message: 'End time cannot be before start time' };
 		}
 
-		const tests = formData
-			.getAll('tests')
+		const test_ids = formData
+			.getAll('tests[]')
 			.map((test) => {
 				try {
 					return JSON.parse(test.toString());
@@ -55,17 +55,20 @@ export const actions: Actions = {
 			})
 			.filter((test) => test !== null);
 
+		const user_ids = formData.getAll('users[]').map((user) => parseInt(user.toString(), 10));
+
 		const updated = await patchStudyAPI(fetch, parseInt(studyId, 10), {
 			title: title,
 			description: description,
 			start_date: formatToUTCDate(startDate),
 			end_date: formatToUTCDate(endDate),
 			nb_session: nbSession,
-			tests: tests,
+			test_ids,
 			consent_participation: consentParticipation,
 			consent_privacy: consentPrivacy,
 			consent_rights: consentRights,
-			consent_study_data: consentStudyData
+			consent_study_data: consentStudyData,
+			user_ids
 		});
 
 		if (!updated) return { message: 'Failed to update study' };
-- 
GitLab


From eba4263ce864fb957d42f871579d0319940877f0 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Fri, 28 Feb 2025 09:10:13 +0100
Subject: [PATCH 23/44] Add gapfill questions & fix frontend to add test to
 study

---
 .../lib/components/studies/Draggable.svelte   | 11 ++++---
 .../lib/components/studies/StudyForm.svelte   |  7 ++---
 frontend/src/lib/types/study.ts               | 13 ++++----
 frontend/src/lib/types/testTaskQuestions.ts   | 31 +++++++++++++------
 4 files changed, 37 insertions(+), 25 deletions(-)

diff --git a/frontend/src/lib/components/studies/Draggable.svelte b/frontend/src/lib/components/studies/Draggable.svelte
index 2b1a4045..84aa0fd0 100644
--- a/frontend/src/lib/components/studies/Draggable.svelte
+++ b/frontend/src/lib/components/studies/Draggable.svelte
@@ -1,6 +1,7 @@
 <script lang="ts">
 	import { t } from '$lib/services/i18n';
 	import Survey from '$lib/types/survey';
+	import { TestTask } from '$lib/types/tests';
 	import autosize from 'svelte-autosize';
 
 	let { items = $bindable(), name } = $props();
@@ -39,10 +40,10 @@
 
 <ul class="w-full">
 	{#each items as item}
-		{#if item instanceof Survey}
-			<input type="hidden" {name} value={JSON.stringify({ type: 'survey', id: item.id })} />
+		{#if item instanceof TestTask}
+			<input type="hidden" {name} value={item.id} />
 		{:else}
-			<input type="hidden" {name} value={JSON.stringify({ type: 'typing' })} />
+			<input type="hidden" {name} value={item.id} />
 		{/if}
 	{/each}
 	{#each items as item, index}
@@ -53,9 +54,9 @@
         {index === overIndex ? 'border-dashed border-2 border-blue-500' : ''}"
 		>
 			<div class="w-full">
-				{#if item instanceof Survey}
+				{#if item instanceof TestTask}
 					{item.title} ({item.groups.length}
-					{$t('utils.words.groups')}, {item.nQuestions}
+					{$t('utils.words.groups')}, {item.numQuestions}
 					{$t('utils.words.questions')})
 				{:else}
 					<div class="mb-2">{item.name}</div>
diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte
index 09307ebc..aa4453e6 100644
--- a/frontend/src/lib/components/studies/StudyForm.svelte
+++ b/frontend/src/lib/components/studies/StudyForm.svelte
@@ -8,8 +8,7 @@
 	import { t } from '$lib/services/i18n';
 	import Survey from '$lib/types/survey';
 	import User from '$lib/types/user';
-	import SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
-	import type Study from '$lib/types/study.js';
+	import type Study from '$lib/types/study';
 	import { onMount } from 'svelte';
 	import { TestTask, type Test } from '$lib/types/tests';
 
@@ -33,7 +32,7 @@
 	let startDate = study ? study.startDate : new Date();
 	let endDate = study ? study.endDate : new Date();
 	let nbSession = study ? study.nbSession : 8;
-	let tests = study ? [...study.tests] : [];
+	let tests = $state(study ? [...study.tests] : []);
 	let consentParticipation =
 		form?.consentParticipation ??
 		(study ? study.consentParticipation : $t('studies.consentParticipation'));
@@ -46,7 +45,7 @@
 
 	let newUsername: string = $state('');
 	let newUserModal = $state(false);
-	let selectedTest: SurveyTypingSvelte | Survey | undefined = $state();
+	let selectedTest: Test | undefined = $state();
 	let users: User[] = $state(study?.users ?? []);
 
 	/**
diff --git a/frontend/src/lib/types/study.ts b/frontend/src/lib/types/study.ts
index 3a135c17..89871905 100644
--- a/frontend/src/lib/types/study.ts
+++ b/frontend/src/lib/types/study.ts
@@ -9,8 +9,7 @@ import { parseToLocalDate } from '$lib/utils/date';
 import { toastAlert } from '$lib/utils/toasts';
 import type { fetchType } from '$lib/utils/types';
 import User from './user';
-import SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
-import Survey from '$lib/types/survey';
+import type { Test } from './tests';
 
 export default class Study {
 	private _id: number;
@@ -24,7 +23,7 @@ export default class Study {
 	private _consentPrivacy: string;
 	private _consentRights: string;
 	private _consentStudyData: string;
-	private _tests: (SurveyTypingSvelte | Survey)[];
+	private _tests: Test[];
 
 	private constructor(
 		id: number,
@@ -38,7 +37,7 @@ export default class Study {
 		consentPrivacy: string,
 		consentRights: string,
 		consentStudyData: string,
-		tests: (SurveyTypingSvelte | Survey)[]
+		tests: Test[]
 	) {
 		this._id = id;
 		this._title = title;
@@ -142,11 +141,11 @@ export default class Study {
 		this._consentStudyData = value;
 	}
 
-	get tests(): (SurveyTypingSvelte | Survey)[] {
+	get tests(): Test[] {
 		return this._tests;
 	}
 
-	set tests(value: (SurveyTypingSvelte | Survey)[]) {
+	set tests(value: Test[]) {
 		this._tests = value;
 	}
 
@@ -165,7 +164,7 @@ export default class Study {
 		consentPrivacy: string,
 		consentRights: string,
 		consentStudyData: string,
-		tests: (SurveyTypingSvelte | Survey)[],
+		tests: Test[],
 		f: fetchType = fetch
 	): Promise<Study | null> {
 		const id = await createStudyAPI(
diff --git a/frontend/src/lib/types/testTaskQuestions.ts b/frontend/src/lib/types/testTaskQuestions.ts
index d5ce0802..77381799 100644
--- a/frontend/src/lib/types/testTaskQuestions.ts
+++ b/frontend/src/lib/types/testTaskQuestions.ts
@@ -23,8 +23,7 @@ export abstract class TestTaskQuestion {
 		if (data.question_qcm) {
 			return TestTaskQuestionQcm.parse(data);
 		}
-
-		return null;
+		return TestTaskQuestionGapfill.parse(data);
 	}
 
 	static parseAll(data: any): TestTaskQuestion[] {
@@ -69,15 +68,29 @@ export class TestTaskQuestionQcm extends TestTaskQuestion {
 			data.question_qcm.correct
 		);
 	}
+}
+
+export class TestTaskQuestionGapfill extends TestTaskQuestion {
+	get answer(): string {
+		const match = super.question.match(/<([^>]+)>/);
+		return match ? match[1] : '';
+	}
 
-	static parseAll(data: any): TestTaskQuestionQcm[] {
+	get length(): number {
+		return this.answer.length;
+	}
+
+	get blank(): string {
+		const question = super.question;
+		const match = question.match(/<([^>]+)>/);
+		if (!match) return question;
+		return question.replace(/<[^>]+>/, '_'.repeat(this.length));
+	}
+
+	static parse(data: any): TestTaskQuestionGapfill | null {
 		if (data === null) {
-			return [];
+			return null;
 		}
-		return data
-			.map((question: any) => TestTaskQuestionQcm.parse(question))
-			.filter(
-				(question: TestTaskQuestionQcm | null): question is TestTaskQuestionQcm => question !== null
-			);
+		return new TestTaskQuestionGapfill(data.id, data.question);
 	}
 }
-- 
GitLab


From 64f15a1c8e4b1c3f92617b496eb03a3ed65c3314 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Fri, 28 Feb 2025 09:40:50 +0100
Subject: [PATCH 24/44] Allow to add tests to a study

---
 backend/app/crud/studies.py                       |  3 ---
 frontend/src/lib/api/studies.ts                   | 10 ++++++----
 .../src/lib/components/studies/Draggable.svelte   |  1 -
 .../src/lib/components/studies/StudyForm.svelte   |  3 +--
 frontend/src/lib/types/study.ts                   | 15 +++++++++------
 .../src/routes/admin/studies/new/+page.server.ts  |  7 +++++--
 6 files changed, 21 insertions(+), 18 deletions(-)

diff --git a/backend/app/crud/studies.py b/backend/app/crud/studies.py
index 53bbe783..2e4b676f 100644
--- a/backend/app/crud/studies.py
+++ b/backend/app/crud/studies.py
@@ -26,8 +26,6 @@ def update_study(db: Session, study: schemas.StudyCreate, study_id: int) -> None
         {**study.model_dump(exclude_unset=True, exclude={"user_ids", "test_ids"})}
     )
 
-    print(study.model_fields_set)
-
     if study.model_fields_set & {"user_ids", "test_ids"}:
         if (
             study_obj := db.query(models.Study)
@@ -35,7 +33,6 @@ def update_study(db: Session, study: schemas.StudyCreate, study_id: int) -> None
             .first()
         ):
             if "user_ids" in study.model_fields_set:
-                print(study_obj.users, study.user_ids)
                 study_obj.users = (
                     db.query(models.User)
                     .filter(models.User.id.in_(study.user_ids))
diff --git a/frontend/src/lib/api/studies.ts b/frontend/src/lib/api/studies.ts
index 82eea784..c4fa2439 100644
--- a/frontend/src/lib/api/studies.ts
+++ b/frontend/src/lib/api/studies.ts
@@ -27,11 +27,12 @@ export async function createStudyAPI(
 	startDate: Date,
 	endDate: Date,
 	nbSession: number,
-	tests: { type: string; id?: number }[],
+	test_ids: number[],
 	consentParticipation: string,
 	consentPrivacy: string,
 	consentRights: string,
-	consentStudyData: string
+	consentStudyData: string,
+	user_ids: number[]
 ): Promise<number | null> {
 	const response = await fetch('/api/studies', {
 		method: 'POST',
@@ -42,11 +43,12 @@ export async function createStudyAPI(
 			start_date: formatToUTCDate(startDate),
 			end_date: formatToUTCDate(endDate),
 			nb_session: nbSession,
-			//tests,
+			test_ids,
 			consent_participation: consentParticipation,
 			consent_privacy: consentPrivacy,
 			consent_rights: consentRights,
-			consent_study_data: consentStudyData
+			consent_study_data: consentStudyData,
+			user_ids
 		})
 	});
 	if (!response.ok) return null;
diff --git a/frontend/src/lib/components/studies/Draggable.svelte b/frontend/src/lib/components/studies/Draggable.svelte
index 84aa0fd0..faa6f9ab 100644
--- a/frontend/src/lib/components/studies/Draggable.svelte
+++ b/frontend/src/lib/components/studies/Draggable.svelte
@@ -1,6 +1,5 @@
 <script lang="ts">
 	import { t } from '$lib/services/i18n';
-	import Survey from '$lib/types/survey';
 	import { TestTask } from '$lib/types/tests';
 	import autosize from 'svelte-autosize';
 
diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte
index aa4453e6..423f6dff 100644
--- a/frontend/src/lib/components/studies/StudyForm.svelte
+++ b/frontend/src/lib/components/studies/StudyForm.svelte
@@ -6,7 +6,6 @@
 	import { getUserByEmailAPI } from '$lib/api/users';
 	import { Icon, MagnifyingGlass } from 'svelte-hero-icons';
 	import { t } from '$lib/services/i18n';
-	import Survey from '$lib/types/survey';
 	import User from '$lib/types/user';
 	import type Study from '$lib/types/study';
 	import { onMount } from 'svelte';
@@ -157,7 +156,7 @@
 
 		<!-- Tests Section -->
 		<h3 class="py-2 px-1">{$t('Tests')}</h3>
-		<Draggable bind:items={tests} name="tests" />
+		<Draggable bind:items={tests} name="tests[]" />
 		<div class="flex">
 			<select class="select select-bordered flex-grow" bind:value={selectedTest}>
 				{#each possibleTests as test}
diff --git a/frontend/src/lib/types/study.ts b/frontend/src/lib/types/study.ts
index 89871905..16f235ef 100644
--- a/frontend/src/lib/types/study.ts
+++ b/frontend/src/lib/types/study.ts
@@ -9,7 +9,7 @@ import { parseToLocalDate } from '$lib/utils/date';
 import { toastAlert } from '$lib/utils/toasts';
 import type { fetchType } from '$lib/utils/types';
 import User from './user';
-import type { Test } from './tests';
+import { Test } from './tests';
 
 export default class Study {
 	private _id: number;
@@ -165,6 +165,7 @@ export default class Study {
 		consentRights: string,
 		consentStudyData: string,
 		tests: Test[],
+		users: User[],
 		f: fetchType = fetch
 	): Promise<Study | null> {
 		const id = await createStudyAPI(
@@ -174,11 +175,12 @@ export default class Study {
 			startDate,
 			endDate,
 			nbSession,
-			[],
+			tests.map((t) => t.id),
 			consentParticipation,
 			consentPrivacy,
 			consentRights,
-			consentStudyData
+			consentStudyData,
+			users.map((u) => u.id)
 		);
 
 		if (id) {
@@ -189,7 +191,7 @@ export default class Study {
 				startDate,
 				endDate,
 				nbSession,
-				[],
+				users,
 				consentParticipation,
 				consentPrivacy,
 				consentRights,
@@ -286,15 +288,16 @@ export default class Study {
 			parseToLocalDate(json.start_date),
 			parseToLocalDate(json.end_date),
 			json.nb_session,
-			json.tests || [],
+			[],
 			json.consent_participation,
 			json.consent_privacy,
 			json.consent_rights,
 			json.consent_study_data,
-			json.tests || []
+			[]
 		);
 
 		study._users = User.parseAll(json.users);
+		study._tests = Test.parseAll(json.tests);
 
 		return study;
 	}
diff --git a/frontend/src/routes/admin/studies/new/+page.server.ts b/frontend/src/routes/admin/studies/new/+page.server.ts
index 6abdbfb7..be9f5194 100644
--- a/frontend/src/routes/admin/studies/new/+page.server.ts
+++ b/frontend/src/routes/admin/studies/new/+page.server.ts
@@ -58,7 +58,7 @@ export const actions: Actions = {
 		}
 
 		const tests = formData
-			.getAll('tests')
+			.getAll('tests[]')
 			.map((test) => {
 				try {
 					return JSON.parse(test.toString());
@@ -68,6 +68,8 @@ export const actions: Actions = {
 			})
 			.filter((test) => test !== null);
 
+		const user_ids = formData.getAll('users[]').map((user) => parseInt(user.toString(), 10));
+
 		const id = await createStudyAPI(
 			fetch,
 			title,
@@ -79,7 +81,8 @@ export const actions: Actions = {
 			consentParticipation,
 			consentPrivacy,
 			consentRights,
-			consentStudyData
+			consentStudyData,
+			user_ids
 		);
 
 		if (id === null) {
-- 
GitLab


From 4298770510dda75397c6ec6e10c370127ec95865 Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Fri, 28 Feb 2025 10:14:40 +0100
Subject: [PATCH 25/44] possible tests selection

---
 frontend/src/lib/components/studies/StudyForm.svelte | 5 +++--
 frontend/src/routes/admin/studies/[id]/+page.svelte  | 4 +++-
 frontend/src/routes/admin/studies/new/+page.svelte   | 4 +++-
 3 files changed, 9 insertions(+), 4 deletions(-)

diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte
index 423f6dff..56eb3339 100644
--- a/frontend/src/lib/components/studies/StudyForm.svelte
+++ b/frontend/src/lib/components/studies/StudyForm.svelte
@@ -10,6 +10,7 @@
 	import type Study from '$lib/types/study';
 	import { onMount } from 'svelte';
 	import { TestTask, type Test } from '$lib/types/tests';
+	import type SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
 
 	let {
 		study = $bindable(),
@@ -19,7 +20,7 @@
 		form
 	}: {
 		study: Study | null;
-		possibleTests: Test[];
+		possibleTests: (Test | SurveyTypingSvelte)[];
 		mode: string; //"create" or "edit"
 		data: any;
 		form: any;
@@ -165,7 +166,7 @@
 							>{test.title} - {test.groups.length} groups - {test.numQuestions} questions</option
 						>
 					{:else}
-						<!-- <option value={test}>{test.name}</option> -->
+						<option value={test}>{test.name}</option>
 					{/if}
 				{/each}
 			</select>
diff --git a/frontend/src/routes/admin/studies/[id]/+page.svelte b/frontend/src/routes/admin/studies/[id]/+page.svelte
index 82f06423..4c588c7c 100644
--- a/frontend/src/routes/admin/studies/[id]/+page.svelte
+++ b/frontend/src/routes/admin/studies/[id]/+page.svelte
@@ -1,11 +1,13 @@
 <script lang="ts">
 	import StudyForm from '$lib/components/studies/StudyForm.svelte';
 	import type { PageData, ActionData } from './$types';
+	import SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
 
 	let { data, form }: { data: PageData; form: ActionData } = $props();
 	let study = data.study;
 
-	let possibleTests = data.tests;
+	let typing = $state(new SurveyTypingSvelte());
+	let possibleTests = [typing, ...data.tests];
 
 	let mode = 'edit';
 </script>
diff --git a/frontend/src/routes/admin/studies/new/+page.svelte b/frontend/src/routes/admin/studies/new/+page.svelte
index c77221cf..25a9047f 100644
--- a/frontend/src/routes/admin/studies/new/+page.svelte
+++ b/frontend/src/routes/admin/studies/new/+page.svelte
@@ -1,10 +1,12 @@
 <script lang="ts">
 	import StudyForm from '$lib/components/studies/StudyForm.svelte';
 	import type { PageData, ActionData } from './$types';
+	import SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
 
 	let { data, form }: { data: PageData; form: ActionData } = $props();
 	let study = null;
-	let possibleTests = data.tests;
+	let typing = $state(new SurveyTypingSvelte());
+	let possibleTests = [typing, ...data.tests];
 	let mode = 'create';
 </script>
 
-- 
GitLab


From 7c3672259eb2000138f84a8d53b17c5351ef1af6 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Fri, 28 Feb 2025 16:35:53 +0100
Subject: [PATCH 26/44] Implement typing tests

---
 backend/app/crud/studies.py                   | 21 +++++-
 backend/app/crud/tests.py                     |  5 ++
 backend/app/models/tests.py                   |  4 +-
 backend/app/schemas/tests.py                  | 74 ++++++++++---------
 frontend/src/lang/fr.json                     |  7 +-
 .../lib/components/studies/Draggable.svelte   | 45 +----------
 .../lib/components/studies/StudyForm.svelte   | 29 +++++---
 frontend/src/lib/types/tests.ts               | 60 ++++++++++-----
 .../src/routes/admin/studies/[id]/+page.ts    |  2 +
 scripts/surveys/survey_maker.py               | 49 +++++++++++-
 scripts/surveys/tests_typing.csv              |  5 ++
 11 files changed, 191 insertions(+), 110 deletions(-)
 create mode 100644 scripts/surveys/tests_typing.csv

diff --git a/backend/app/crud/studies.py b/backend/app/crud/studies.py
index 2e4b676f..1c60d698 100644
--- a/backend/app/crud/studies.py
+++ b/backend/app/crud/studies.py
@@ -6,10 +6,29 @@ import schemas
 
 
 def create_study(db: Session, study: schemas.StudyCreate) -> models.Study:
-    db_study = models.Study(**study.model_dump())
+    db_study = models.Study(
+        **study.model_dump(exclude_unset=True, exclude={"user_ids", "test_ids"})
+    )
+
+    if study.model_fields_set & {"user_ids", "test_ids"}:
+        if db_study:
+            if "user_ids" in study.model_fields_set:
+                db_study.users = (
+                    db.query(models.User)
+                    .filter(models.User.id.in_(study.user_ids))
+                    .all()
+                )
+            if "test_ids" in study.model_fields_set:
+                db_study.tests = (
+                    db.query(models.Test)
+                    .filter(models.Test.id.in_(study.test_ids))
+                    .all()
+                )
+
     db.add(db_study)
     db.commit()
     db.refresh(db_study)
+
     return db_study
 
 
diff --git a/backend/app/crud/tests.py b/backend/app/crud/tests.py
index 4ae42314..da3c9b83 100644
--- a/backend/app/crud/tests.py
+++ b/backend/app/crud/tests.py
@@ -22,6 +22,8 @@ def get_test(db: Session, test_id: int) -> models.Test:
 
 def delete_test(db: Session, test_id: int) -> None:
     db.query(models.Test).filter(models.Test.id == test_id).delete()
+    db.query(models.TestTask).filter(models.TestTask.test_id == test_id).delete()
+    db.query(models.TestTyping).filter(models.TestTyping.test_id == test_id).delete()
     db.commit()
 
 
@@ -104,6 +106,9 @@ def delete_question(db: Session, question_id: int):
     db.query(models.TestTaskQuestion).filter(
         models.TestTaskQuestion.id == question_id
     ).delete()
+    db.query(models.TestTaskQuestionQCM).filter(
+        models.TestTaskQuestionQCM.question_id == question_id
+    ).delete()
     db.commit()
     return None
 
diff --git a/backend/app/models/tests.py b/backend/app/models/tests.py
index 3d2b9e6c..0de8a991 100644
--- a/backend/app/models/tests.py
+++ b/backend/app/models/tests.py
@@ -8,6 +8,7 @@ class TestTyping(Base):
     __tablename__ = "test_typings"
 
     test_id = Column(Integer, ForeignKey("tests.id"), primary_key=True)
+    explanations = Column(String, nullable=True)
     text = Column(String, nullable=False)
     repeat = Column(Integer, nullable=False, default=1)
     duration = Column(Integer, nullable=False, default=0)
@@ -38,7 +39,6 @@ class TestTypingEntry(Base):
 class TestTask(Base):
     __tablename__ = "test_tasks"
     test_id = Column(Integer, ForeignKey("tests.id"), primary_key=True)
-    title = Column(String, nullable=False)
 
     test = relationship(
         "Test", uselist=False, back_populates="test_task", lazy="selectin"
@@ -54,6 +54,7 @@ class Test(Base):
     __tablename__ = "tests"
 
     id = Column(Integer, primary_key=True, index=True)
+    title = Column(String, nullable=False)
 
     test_typing = relationship(
         "TestTyping",
@@ -92,6 +93,7 @@ class TestTaskGroup(Base):
 
     title = Column(String, nullable=False)
     demo = Column(Boolean, default=False)
+    randomize = Column(Boolean, default=True)
 
     questions = relationship(
         "TestTaskQuestion",
diff --git a/backend/app/schemas/tests.py b/backend/app/schemas/tests.py
index e2c1ba92..63f6ad4f 100644
--- a/backend/app/schemas/tests.py
+++ b/backend/app/schemas/tests.py
@@ -2,40 +2,6 @@ from typing_extensions import Self
 from pydantic import BaseModel, model_validator
 
 
-class TestTypingCreate(BaseModel):
-    text: str
-    repeat: int | None = None
-    duration: int | None = None
-
-
-class TestTaskCreate(BaseModel):
-    title: str
-
-
-class TestCreate(BaseModel):
-    # TODO remove
-    id: int | None = None
-    test_typing: TestTypingCreate | None = None
-    test_task: TestTaskCreate | None = None
-
-    @model_validator(mode="after")
-    def check_test_type(self) -> Self:
-        if self.test_typing is None and self.test_task is None:
-            raise ValueError("TypingTest or TaskTest must be provided")
-        if self.test_typing is not None and self.test_task is not None:
-            raise ValueError(
-                "TypingTest and TaskTest cannot be provided at the same time"
-            )
-        return self
-
-
-class TestTaskGroupCreate(BaseModel):
-    # TODO remove
-    id: int | None = None
-    title: str
-    demo: bool = False
-
-
 class TestTaskQuestionQCMCreate(BaseModel):
     correct: int
     option1: str | None = None
@@ -93,21 +59,59 @@ class TestTaskQuestion(BaseModel):
     question_qcm: TestTaskQuestionQCM | None = None
 
 
+class TestTaskGroupCreate(BaseModel):
+    # TODO remove
+    id: int | None = None
+    title: str
+    demo: bool = False
+    randomize: bool = True
+
+
+class TestTypingCreate(BaseModel):
+    explanations: str
+    text: str
+    repeat: int | None = None
+    duration: int | None = None
+
+
 class TestTaskGroup(TestTaskGroupCreate):
     # id: int
     questions: list[TestTaskQuestion] = []
 
 
-class TestTask(TestTaskCreate):
+class TestTaskCreate(BaseModel):
     groups: list[TestTaskGroup] = []
 
 
+class TestTask(TestTaskCreate):
+    pass
+
+
+class TestCreate(BaseModel):
+    # TODO remove
+    id: int | None = None
+    title: str
+    test_typing: TestTypingCreate | None = None
+    test_task: TestTaskCreate | None = None
+
+    @model_validator(mode="after")
+    def check_test_type(self) -> Self:
+        if self.test_typing is None and self.test_task is None:
+            raise ValueError("TypingTest or TaskTest must be provided")
+        if self.test_typing is not None and self.test_task is not None:
+            raise ValueError(
+                "TypingTest and TaskTest cannot be provided at the same time"
+            )
+        return self
+
+
 class TestTyping(TestTypingCreate):
     pass
 
 
 class Test(BaseModel):
     id: int
+    title: str
     test_typing: TestTyping | None = None
     test_task: TestTask | None = None
 
diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json
index 91889c13..a28ceb3a 100644
--- a/frontend/src/lang/fr.json
+++ b/frontend/src/lang/fr.json
@@ -379,6 +379,10 @@
 		"consentRights": "Votre participation à cette étude est volontaire. Vous pouvez à tout moment décider de ne plus participer à l'étude sans avoir à vous justifier. Vous pouvez également demander à ce que vos données soient supprimées à tout moment. Si vous avez des questions ou des préoccupations concernant cette étude, vous pouvez contacter le responsable de l'étude.",
 		"consentStudyData": "Informations sur l'étude."
 	},
+	"tests": {
+		"taskTests": "Tests de langue",
+		"typingTests": "Tests de frappe"
+	},
 	"button": {
 		"create": "Créer",
 		"submit": "Envoyer",
@@ -477,7 +481,8 @@
 			"email": "E-mail",
 			"toggle": "Participants",
 			"groups": "groupes",
-			"questions": "questions"
+			"questions": "questions",
+			"tests": "tests"
 		}
 	},
 	"inputs": {
diff --git a/frontend/src/lib/components/studies/Draggable.svelte b/frontend/src/lib/components/studies/Draggable.svelte
index faa6f9ab..95fc21e0 100644
--- a/frontend/src/lib/components/studies/Draggable.svelte
+++ b/frontend/src/lib/components/studies/Draggable.svelte
@@ -1,7 +1,6 @@
 <script lang="ts">
 	import { t } from '$lib/services/i18n';
-	import { TestTask } from '$lib/types/tests';
-	import autosize from 'svelte-autosize';
+	import { TestTask, TestTyping } from '$lib/types/tests';
 
 	let { items = $bindable(), name } = $props();
 	let draggedIndex: number | null = $state(null);
@@ -39,11 +38,7 @@
 
 <ul class="w-full">
 	{#each items as item}
-		{#if item instanceof TestTask}
-			<input type="hidden" {name} value={item.id} />
-		{:else}
-			<input type="hidden" {name} value={item.id} />
-		{/if}
+		<input type="hidden" {name} value={item.id} />
 	{/each}
 	{#each items as item, index}
 		<li
@@ -57,40 +52,8 @@
 					{item.title} ({item.groups.length}
 					{$t('utils.words.groups')}, {item.numQuestions}
 					{$t('utils.words.questions')})
-				{:else}
-					<div class="mb-2">{item.name}</div>
-					<label class="label text-sm" for="typing_input">{$t('studies.typingTestText')}*</label>
-					<textarea
-						use:autosize
-						class="input w-full"
-						id="typing_input"
-						name="typing_input"
-						bind:value={item.text}
-						required
-					></textarea>
-					<div class="flex flex-wrap items-center gap-2 text-sm">
-						<label class="label" for="typing_repetition">{$t('studies.typingTestRepetition')}</label
-						>
-						<input
-							class="input w-20"
-							type="number"
-							id="typing_repetition"
-							name="typing_repetition"
-							bind:value={item.repetition}
-						/>
-						{$t('studies.andOr')}
-						<label class="label" for="typing_time">{$t('studies.typingTestDuration')}</label>
-						<input
-							class="input w-20"
-							type="number"
-							id="typing_time"
-							name="typing_time"
-							bind:value={item.duration}
-						/>
-						<div class="tooltip" data-tip={$t('studies.typingTestInfoNote')}>
-							<span class="ml-1 cursor-pointer font-semibold">ⓘ</span>
-						</div>
-					</div>
+				{:else if item instanceof TestTyping}
+					{item.title}
 				{/if}
 			</div>
 			<div
diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte
index 56eb3339..316dc64f 100644
--- a/frontend/src/lib/components/studies/StudyForm.svelte
+++ b/frontend/src/lib/components/studies/StudyForm.svelte
@@ -9,7 +9,7 @@
 	import User from '$lib/types/user';
 	import type Study from '$lib/types/study';
 	import { onMount } from 'svelte';
-	import { TestTask, type Test } from '$lib/types/tests';
+	import { TestTask, TestTyping, type Test } from '$lib/types/tests';
 	import type SurveyTypingSvelte from '$lib/types/surveyTyping.svelte';
 
 	let {
@@ -156,19 +156,26 @@
 		/>
 
 		<!-- Tests Section -->
-		<h3 class="py-2 px-1">{$t('Tests')}</h3>
+		<h3 class="py-2 px-1 capitalize">{$t('utils.words.tests')}</h3>
 		<Draggable bind:items={tests} name="tests[]" />
 		<div class="flex">
 			<select class="select select-bordered flex-grow" bind:value={selectedTest}>
-				{#each possibleTests as test}
-					{#if test instanceof TestTask}
-						<option value={test}
-							>{test.title} - {test.groups.length} groups - {test.numQuestions} questions</option
-						>
-					{:else}
-						<option value={test}>{test.name}</option>
-					{/if}
-				{/each}
+				<optgroup label={$t('tests.taskTests')}>
+					{#each possibleTests as test}
+						{#if test instanceof TestTask}
+							<option value={test}
+								>{test.title} - {test.groups.length} groups - {test.numQuestions} questions</option
+							>
+						{/if}
+					{/each}
+				</optgroup>
+				<optgroup label={$t('tests.typingTests')}>
+					{#each possibleTests as test}
+						{#if test instanceof TestTyping}
+							<option value={test}>{test.title}</option>
+						{/if}
+					{/each}
+				</optgroup>
 			</select>
 			<button
 				class="ml-2 button"
diff --git a/frontend/src/lib/types/tests.ts b/frontend/src/lib/types/tests.ts
index ba781d44..1dc4b1ca 100644
--- a/frontend/src/lib/types/tests.ts
+++ b/frontend/src/lib/types/tests.ts
@@ -3,15 +3,21 @@ import TestTaskGroup from './testTaskGroups';
 
 export abstract class Test {
 	private _id: number;
+	private _title: string;
 
-	constructor(id: number) {
+	constructor(id: number, title: string) {
 		this._id = id;
+		this._title = title;
 	}
 
 	get id(): number {
 		return this._id;
 	}
 
+	get title(): string {
+		return this._title;
+	}
+
 	static parse(data: any): Test | null {
 		if (data === null) {
 			toastAlert('Failed to parse test data');
@@ -42,19 +48,13 @@ export abstract class Test {
 }
 
 export class TestTask extends Test {
-	private _title: string;
 	private _groups: TestTaskGroup[];
 
 	constructor(id: number, title: string, groups: TestTaskGroup[]) {
-		super(id);
-		this._title = title;
+		super(id, title);
 		this._groups = groups;
 	}
 
-	get title(): string {
-		return this._title;
-	}
-
 	get groups(): TestTaskGroup[] {
 		return this._groups;
 	}
@@ -71,19 +71,45 @@ export class TestTask extends Test {
 
 		const groups = TestTaskGroup.parseAll(data.test_task.groups);
 
-		return new TestTask(data.id, data.test_task.title, groups);
+		return new TestTask(data.id, data.title, groups);
 	}
+}
+
+export class TestTyping extends Test {
+	private _text: string;
+	private _duration: number;
+	private _repeat: number;
 
-	static parseAll(data: any): TestTask[] {
+	constructor(id: number, title: string, text: string, duration: number, repeat: number) {
+		super(id, title);
+		this._text = text;
+		this._duration = duration;
+		this._repeat = repeat;
+	}
+
+	get text(): string {
+		return this._text;
+	}
+
+	get duration(): number {
+		return this._duration;
+	}
+
+	get repeat(): number {
+		return this._repeat;
+	}
+
+	static parse(data: any): TestTyping | null {
 		if (data === null) {
 			toastAlert('Failed to parse test data');
-			return [];
+			return null;
 		}
-
-		return data
-			.map((test: any) => TestTask.parse(test))
-			.filter((test: TestTask | null): test is TestTask => test !== null);
+		return new TestTyping(
+			data.id,
+			data.title,
+			data.test_typing.text,
+			data.test_typing.duration,
+			data.test_typing.repeat
+		);
 	}
 }
-
-export class TestTyping extends Test {}
diff --git a/frontend/src/routes/admin/studies/[id]/+page.ts b/frontend/src/routes/admin/studies/[id]/+page.ts
index ba7b272d..ba78bf42 100644
--- a/frontend/src/routes/admin/studies/[id]/+page.ts
+++ b/frontend/src/routes/admin/studies/[id]/+page.ts
@@ -8,12 +8,14 @@ export const load: Load = async ({ fetch, params }) => {
 	const id = Number(params.id);
 
 	const study = Study.parse(await getStudyAPI(fetch, id));
+	console.log(study);
 
 	if (!study) {
 		redirect(303, '/admin/studies');
 	}
 
 	const tests = Test.parseAll(await getSurveysAPI(fetch));
+	console.log(tests);
 
 	return {
 		tests,
diff --git a/scripts/surveys/survey_maker.py b/scripts/surveys/survey_maker.py
index bca92b48..8d1b3d9c 100644
--- a/scripts/surveys/survey_maker.py
+++ b/scripts/surveys/survey_maker.py
@@ -90,7 +90,7 @@ with open("groups.csv") as file:
         its = [int(x) for x in its if x]
         groups.append({"id": id_, "title": title, "demo": demo_, "items_id": its})
 
-# PARSE SURVEYS
+# PARSE TASK TESTS
 
 tests_task = []
 with open("tests_task.csv") as file:
@@ -101,7 +101,28 @@ with open("tests_task.csv") as file:
         id_, title, *gps = line.split(",")
         id_ = int(id_)
         gps = [int(x) for x in gps if x]
-        tests_task.append({"id": id_, "test_task": {"title": title}, "groups_id": gps})
+        tests_task.append(
+            {"id": id_, "title": title, "test_task": {"groups": []}, "groups_id": gps}
+        )
+
+# PARSE TYPING TESTS
+
+df_typing_test = pd.read_csv("tests_typing.csv", dtype=str)
+
+tests_typing = []
+for i, row in df_typing_test.iterrows():
+    tests_typing.append(
+        {
+            "id": int(row["id"]),
+            "title": row["title"],
+            "test_typing": {
+                "explanations": row["explanations"],
+                "text": row["text"],
+                "repeat": int(str(row["repeat"])),
+                "duration": int(str(row["duration"])),
+            },
+        }
+    )
 
 # SESSION DATA
 
@@ -229,6 +250,28 @@ for t in tests_task:
         if r.status_code not in [201]:
             print(f'Failed to add group {gp} to test {t["id"]}: {r.text}')
             break
+else:
+    print(f"Successfully created {n_task_tests}/{len(tests_task)} task tests")
+
+# CREATE TYPING TESTS
+
+n_typing_tests = 0
+
+for t in tests_typing:
+    assert session.delete(f'{API_URL}{API_PATH}/tests/{t["id"]}').status_code in [
+        404,
+        204,
+    ], f'Failed to delete test {t["id"]}'
+
+    r = session.post(f"{API_URL}{API_PATH}/tests", json=t)
+    if r.status_code not in [201]:
+        print(f'Failed to create typing test {t["id"]}: {r.text}')
+        continue
+
+    if r.text != str(t["id"]):
+        print(f'Typing test {t["id"]} was created with id {r.text}')
+
+    n_typing_tests += 1
 
 else:
-    print(f"Successfully created {n_task_tests}/{len(tests_task)} tests")
+    print(f"Successfully created {n_typing_tests}/{len(tests_typing)} typing tests")
diff --git a/scripts/surveys/tests_typing.csv b/scripts/surveys/tests_typing.csv
new file mode 100644
index 00000000..dbd53f98
--- /dev/null
+++ b/scripts/surveys/tests_typing.csv
@@ -0,0 +1,5 @@
+id,title,explanations,text,repeat,duration
+7,"Repeat letters","Repetez les lettres DK autant de fois que possible en 15 secondes. Le chronomètre démarre dès que vous appuyez sur une touche ou sur le boutton commencer. Une vois que vous aurez terminé, appuyez sur le bouton suivant pour passer à l'exercice suivant.","dk",0,15
+8,"Repeat a sentence","Repetez la phrase suivante autant de fois que possible en 30 secondes.","Le chat est sur le toit.",0,30
+9,"Repeat 7","Repetez 7 fois la phrase suivante le plus rapidement possible.","Six animaux mangent",7,0
+
-- 
GitLab


From 76a1f84269fcb384de0f4875acc14f634fc82343 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Fri, 28 Feb 2025 17:02:45 +0100
Subject: [PATCH 27/44] Select study & code

---
 frontend/src/lang/fr.json                     |   6 +-
 frontend/src/lib/utils/toasts.ts              |   3 +
 frontend/src/routes/+layout.server.ts         |   9 +-
 .../src/routes/admin/studies/[id]/+page.ts    |   2 -
 .../src/routes/studies/[[id]]/+page.svelte    | 125 ++++++++++++++++++
 frontend/src/routes/studies/[[id]]/+page.ts   |  25 ++++
 6 files changed, 166 insertions(+), 4 deletions(-)
 create mode 100644 frontend/src/routes/studies/[[id]]/+page.svelte
 create mode 100644 frontend/src/routes/studies/[[id]]/+page.ts

diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json
index a28ceb3a..1091d0b2 100644
--- a/frontend/src/lang/fr.json
+++ b/frontend/src/lang/fr.json
@@ -377,7 +377,11 @@
 		"consentParticipation": "Si vous acceptez de participer, vous serez invité·e à participer à des sessions de tutorat en ligne avec un tuteur de langue étrangère. Vous serez également invité à remplir des questionnaires avant et après les sessions de tutorat. Les sessions de tutorat seront enregistrées pour analyse ultérieure.Nous vous demandons de prévoir de réaliser un minimum de 8 sessions d'une heure de tutorat (donc 8 heures au total), au cours d'une période de 1 à 3 mois. Vous pouvez bien sûr en réaliser plus si vous le souhaitez. Vous pouvez cependant arrêter de participer à l'étude à tout moment.",
 		"consentPrivacy": "Les données collectées (par exemple, les transcriptions des conversations, les résultats de tests, les mesures de frappe, les informations sur les participants comme l'age ou le genre) seront traitées de manière confidentielle et anonyme. Elles seront conservées après leur anonymisation intégrale et ne pourront être utilisées qu'à des fins scientifiques ou pédagogiques. Elles pourront éventuellement être partagées avec d'autres chercheurs ou enseignants, mais toujours dans ce cadre strictement de recherche ou d'enseignement.",
 		"consentRights": "Votre participation à cette étude est volontaire. Vous pouvez à tout moment décider de ne plus participer à l'étude sans avoir à vous justifier. Vous pouvez également demander à ce que vos données soient supprimées à tout moment. Si vous avez des questions ou des préoccupations concernant cette étude, vous pouvez contacter le responsable de l'étude.",
-		"consentStudyData": "Informations sur l'étude."
+		"consentStudyData": "Informations sur l'étude.",
+		"tab": {
+			"study": "Étude",
+			"code": "Code"
+		}
 	},
 	"tests": {
 		"taskTests": "Tests de langue",
diff --git a/frontend/src/lib/utils/toasts.ts b/frontend/src/lib/utils/toasts.ts
index a28dee8e..937d963b 100644
--- a/frontend/src/lib/utils/toasts.ts
+++ b/frontend/src/lib/utils/toasts.ts
@@ -1,6 +1,7 @@
 import { toast } from '@zerodevx/svelte-toast';
 
 export function toastAlert(title: string, subtitle: string = '', persistant: boolean = false) {
+	if (window === undefined) return;
 	toast.push(`<strong>${title}</strong><br>${subtitle}`, {
 		theme: {
 			'--toastBackground': '#ff4d4f',
@@ -15,6 +16,7 @@ export function toastAlert(title: string, subtitle: string = '', persistant: boo
 }
 
 export function toastWarning(title: string, subtitle: string = '', persistant: boolean = false) {
+	if (window === undefined) return;
 	toast.push(`<strong>${title}</strong><br>${subtitle}`, {
 		theme: {
 			'--toastBackground': '#faad14',
@@ -29,6 +31,7 @@ export function toastWarning(title: string, subtitle: string = '', persistant: b
 }
 
 export function toastSuccess(title: string, subtitle: string = '', persistant: boolean = false) {
+	if (window === undefined) return;
 	toast.push(`<strong>${title}</strong><br>${subtitle}`, {
 		theme: {
 			'--toastBackground': '#52c41a',
diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts
index b3aecd87..a1671e66 100644
--- a/frontend/src/routes/+layout.server.ts
+++ b/frontend/src/routes/+layout.server.ts
@@ -1,6 +1,13 @@
 import { type ServerLoad, redirect } from '@sveltejs/kit';
 
-const publicly_allowed = ['/login', '/register', '/tests', '/surveys', '/tutor/register'];
+const publicly_allowed = [
+	'/login',
+	'/register',
+	'/tests',
+	'/surveys',
+	'/tutor/register',
+	'/studies'
+];
 
 const isPublic = (path: string) => {
 	for (const allowed of publicly_allowed) {
diff --git a/frontend/src/routes/admin/studies/[id]/+page.ts b/frontend/src/routes/admin/studies/[id]/+page.ts
index ba78bf42..ba7b272d 100644
--- a/frontend/src/routes/admin/studies/[id]/+page.ts
+++ b/frontend/src/routes/admin/studies/[id]/+page.ts
@@ -8,14 +8,12 @@ export const load: Load = async ({ fetch, params }) => {
 	const id = Number(params.id);
 
 	const study = Study.parse(await getStudyAPI(fetch, id));
-	console.log(study);
 
 	if (!study) {
 		redirect(303, '/admin/studies');
 	}
 
 	const tests = Test.parseAll(await getSurveysAPI(fetch));
-	console.log(tests);
 
 	return {
 		tests,
diff --git a/frontend/src/routes/studies/[[id]]/+page.svelte b/frontend/src/routes/studies/[[id]]/+page.svelte
new file mode 100644
index 00000000..d88936ee
--- /dev/null
+++ b/frontend/src/routes/studies/[[id]]/+page.svelte
@@ -0,0 +1,125 @@
+<script lang="ts">
+	import type Study from '$lib/types/study';
+	import type { PageData } from './$types';
+	import { t } from '$lib/services/i18n';
+	import { displayDate } from '$lib/utils/date';
+	import { toastWarning } from '$lib/utils/toasts';
+	import { get } from 'svelte/store';
+
+	let { data, form }: { data: PageData; form: FormData } = $props();
+	let study: Study | undefined = $state(data.study);
+	let studies: Study[] | undefined = $state(data.studies);
+	let user = $state(data.user);
+
+	let selectedStudy: Study | undefined = $state();
+
+	let current_step = $state(
+		(() => {
+			if (!study) return 0;
+			if (!user) return 1;
+			return 2;
+		})()
+	);
+
+	let code = $state('');
+
+	function checkCode() {
+		if (!code) {
+			toastWarning(get(t)('tests.invalidCode'));
+			return;
+		}
+		if (code.length < 3) {
+			toastWarning(get(t)('tests.invalidCode'));
+			return;
+		}
+
+		current_step += 1;
+	}
+</script>
+
+<div class="header mx-auto my-5">
+	<ul class="steps text-xs">
+		<li class="step" class:step-primary={current_step >= 0}>
+			{$t('studies.tab.study')}
+		</li>
+		<li class="step" class:step-primary={current_step >= 1}>
+			{$t('studies.tab.code')}
+		</li>
+		{#if study}
+			{#each study.tests as test, i (test.id)}
+				<li class="step" class:step-primary={current_step >= i + 2}>
+					{test.title}
+				</li>
+			{/each}
+		{:else}
+			<li class="step" data-content="...">
+				{$t('studies.tab.tests')}
+			</li>
+		{/if}
+	</ul>
+</div>
+
+<div class="max-w-screen-md mx-auto p-5">
+	{#if current_step == 0}
+		<div class="form-control">
+			<label for="study" class="label">
+				<span class="label-text">{$t('register.study')}</span>
+				<span class="label-text-alt">{$t('register.study.note')}</span>
+			</label>
+			<select
+				class="select select-bordered"
+				id="study"
+				name="study"
+				required
+				disabled={!!study}
+				bind:value={selectedStudy}
+			>
+				{#if study}
+					<option selected value={study}>
+						{study.title} ({displayDate(study.startDate)} - {displayDate(study.endDate)})
+					</option>
+				{:else if studies}
+					<option disabled selected value="">{$t('register.study.placeholder')}</option>
+					{#each studies as s}
+						<option value={s}>
+							{s.title} ({displayDate(s.startDate)} - {displayDate(s.endDate)})
+						</option>
+					{/each}
+				{:else}
+					<option disabled></option>
+				{/if}
+			</select>
+		</div>
+		<div class="form-control">
+			<a
+				class="button mt-8"
+				class:btn-disabled={!selectedStudy}
+				href="/studies/{selectedStudy?.id}"
+				data-sveltekit-reload
+			>
+				{$t('button.continue')}
+			</a>
+		</div>
+	{:else if study}
+		{#if current_step == 1}
+			<div class="flex flex-col items-center min-h-screen">
+				<h2 class="mb-10 text-xl text-center">{study.title}</h2>
+				<p class="mb-4 text-lg font-semibold">{$t('surveys.code')}</p>
+				<p class="mb-6 text-sm text-gray-600 text-center">{@html $t('surveys.codeIndication')}</p>
+				<input
+					type="text"
+					placeholder="Code"
+					class="input block mx-auto w-full max-w-xs border border-gray-300 rounded-md py-2 px-3 text-center"
+					onkeydown={(e) => e.key === 'Enter' && checkCode()}
+					bind:value={code}
+				/>
+				<button
+					class="button mt-4 block bg-yellow-500 text-white rounded-md py-2 px-6 hover:bg-yellow-600 transition"
+					onclick={checkCode}
+				>
+					{$t('button.next')}
+				</button>
+			</div>
+		{/if}
+	{/if}
+</div>
diff --git a/frontend/src/routes/studies/[[id]]/+page.ts b/frontend/src/routes/studies/[[id]]/+page.ts
new file mode 100644
index 00000000..d309a7c7
--- /dev/null
+++ b/frontend/src/routes/studies/[[id]]/+page.ts
@@ -0,0 +1,25 @@
+import { getStudiesAPI, getStudyAPI } from '$lib/api/studies';
+import Study from '$lib/types/study.js';
+import type { Load } from '@sveltejs/kit';
+
+export const load: Load = async ({ fetch, params }) => {
+	const sStudyId: string | undefined = params.id;
+	if (sStudyId) {
+		const studyId = parseInt(sStudyId);
+		if (studyId) {
+			const study = Study.parse(await getStudyAPI(fetch, studyId));
+			if (study) {
+				return {
+					study
+				};
+			}
+		}
+	}
+
+	const studies = Study.parseAll(await getStudiesAPI(fetch));
+
+	return {
+		studyError: true,
+		studies
+	};
+};
-- 
GitLab


From c81c65a1182d41cadd49254bf87b7781544d5184 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Sun, 2 Mar 2025 20:30:25 +0100
Subject: [PATCH 28/44] Allow users to take studies

---
 backend/app/models/tests.py                   |   2 +-
 frontend/src/lib/api/tests.ts                 |  35 ++-
 .../lib/components/tests/languageTest.svelte  | 227 ++++++++++++++++++
 frontend/src/lib/types/testTaskGroups.ts      |  16 +-
 frontend/src/lib/types/testTaskQuestions.ts   |  55 ++++-
 frontend/src/lib/utils/arrays.ts              |  12 +
 .../src/routes/studies/[[id]]/+page.svelte    |   7 +
 frontend/src/routes/tests/[id]/+page.svelte   |   2 -
 8 files changed, 346 insertions(+), 10 deletions(-)
 create mode 100644 frontend/src/lib/components/tests/languageTest.svelte
 create mode 100644 frontend/src/lib/utils/arrays.ts

diff --git a/backend/app/models/tests.py b/backend/app/models/tests.py
index 0de8a991..deae27cf 100644
--- a/backend/app/models/tests.py
+++ b/backend/app/models/tests.py
@@ -179,7 +179,7 @@ class TestTaskEntry(Base):
     created_at = Column(DateTime, default=datetime_aware)
     test_task_id = Column(Integer, ForeignKey("test_tasks.test_id"), index=True)
     test_group_id = Column(Integer, ForeignKey("test_task_groups.id"), index=True)
-    question_id = Column(Integer, ForeignKey("test_task_questions.id"), index=True)
+    test_question_id = Column(Integer, ForeignKey("test_task_questions.id"), index=True)
     response_time = Column(Float, nullable=False)
 
     entry_qcm = relationship(
diff --git a/frontend/src/lib/api/tests.ts b/frontend/src/lib/api/tests.ts
index f8c69cd7..8af59694 100644
--- a/frontend/src/lib/api/tests.ts
+++ b/frontend/src/lib/api/tests.ts
@@ -1,10 +1,39 @@
 import type { fetchType } from '$lib/utils/types';
 
-export async function sendTestVocabularyAPI(data: any): Promise<boolean> {
-	const response = await fetch(`/api/tests/vocabulary`, {
+export async function sendTestResponseAPI(
+	fetch: fetchType,
+	code: string,
+	user_id: number | null,
+	test_task_id: number,
+	test_group_id: number,
+	test_question_id: number,
+	response_time: number,
+	qcm_selected_id: number | null,
+	gapfill_text: string | null
+) {
+	const body = {
+		code,
+		user_id,
+		test_task_id,
+		test_group_id,
+		test_question_id,
+		response_time,
+		entry_qcm: null as null | { selected_id: number },
+		entry_gapfill: null as null | { text: string }
+	};
+
+	if (qcm_selected_id !== null) {
+		body.entry_qcm = { selected_id: qcm_selected_id };
+	}
+
+	if (gapfill_text !== null) {
+		body.entry_gapfill = { text: gapfill_text };
+	}
+
+	const response = await fetch(`/api/tests/entries`, {
 		method: 'POST',
 		headers: { 'Content-Type': 'application/json' },
-		body: JSON.stringify(data)
+		body: JSON.stringify(body)
 	});
 
 	return response.ok;
diff --git a/frontend/src/lib/components/tests/languageTest.svelte b/frontend/src/lib/components/tests/languageTest.svelte
new file mode 100644
index 00000000..728d5dc3
--- /dev/null
+++ b/frontend/src/lib/components/tests/languageTest.svelte
@@ -0,0 +1,227 @@
+<script lang="ts">
+	import { t } from '$lib/services/i18n';
+	import type { TestTask } from '$lib/types/tests';
+	import {
+		TestTaskQuestion,
+		TestTaskQuestionGapfill,
+		TestTaskQuestionQcm,
+		TestTaskQuestionQcmType
+	} from '$lib/types/testTaskQuestions';
+	import type User from '$lib/types/user';
+	import Gapfill from '../surveys/gapfill.svelte';
+	import { sendTestResponseAPI } from '$lib/api/tests';
+	import { getSurveyScoreAPI } from '$lib/api/survey';
+
+	let {
+		languageTest,
+		user,
+		code,
+		onFinish = () => {}
+	}: {
+		languageTest: TestTask;
+		user: User | null;
+		code: string | null;
+		onFinish: Function;
+	} = $props();
+
+	function getSortedQuestions(questions: TestTaskQuestion[]) {
+		return questions.sort(() => Math.random() - 0.5);
+	}
+
+	let nAnswers = $state(1);
+
+	let sid =
+		Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
+	let startTime = new Date().getTime();
+
+	let currentGroupId = $state(0);
+	let currentGroup = $derived(languageTest.groups[currentGroupId]);
+
+	let questions = $derived(
+		currentGroup.randomize ? getSortedQuestions(currentGroup.questions) : currentGroup.questions
+	);
+
+	let currentQuestionId = $state(0);
+	let currentQuestion = $derived(questions[currentQuestionId]);
+	let currentQuestionParts = $derived(
+		currentQuestion instanceof TestTaskQuestionGapfill ? currentQuestion.parts : null
+	);
+	$inspect(currentQuestion);
+
+	let soundPlayer: HTMLAudioElement | null = $state(null);
+
+	let selectedOption: string;
+	let finalScore: number | null = $state(null);
+
+	//source: shuffle function code taken from https://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array/18650169#18650169
+	function shuffle(array: string[]) {
+		let currentIndex = array.length;
+		// While there remain elements to shuffle...
+		while (currentIndex != 0) {
+			// Pick a remaining element...
+			let randomIndex = Math.floor(Math.random() * currentIndex);
+			currentIndex--;
+			// And swap it with the current element.
+			[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
+		}
+	}
+
+	function setGroupId(id: number) {
+		currentGroupId = id;
+		if (currentGroup.id < 1100) {
+			setQuestionId(0);
+		}
+	}
+
+	function setQuestionId(id: number) {
+		currentQuestionId = id;
+		if (soundPlayer) soundPlayer.load();
+		nAnswers += 1;
+	}
+
+	async function nextGroup() {
+		if (currentGroupId < languageTest.groups.length - 1) {
+			setGroupId(currentGroupId + 1);
+			//special group id for end of survey questions
+		} else {
+			const scoreData = await getSurveyScoreAPI(fetch, languageTest.id, sid);
+			if (scoreData) {
+				finalScore = scoreData.score;
+			}
+			onFinish();
+		}
+	}
+
+	async function sendGap() {
+		if (
+			!currentGroup.demo &&
+			currentQuestion instanceof TestTaskQuestionGapfill &&
+			currentQuestionParts
+		) {
+			const gapTexts = currentQuestionParts
+				.filter((part) => part.gap !== null)
+				.map((part) => part.gap)
+				.join('|');
+
+			if (
+				!(await sendTestResponseAPI(
+					fetch,
+					code || user?.email || '',
+					user?.id || null,
+					languageTest.id,
+					currentGroup.id,
+					questions[currentQuestionId].id,
+					(new Date().getTime() - startTime) / 1000,
+					null,
+					gapTexts
+				))
+			) {
+				return;
+			}
+		}
+		if (currentQuestionId < questions.length - 1) {
+			setQuestionId(currentQuestionId + 1);
+			startTime = new Date().getTime();
+		} else {
+			nextGroup();
+		}
+	}
+
+	async function selectOption(option: number) {
+		if (!currentGroup.demo) {
+			if (
+				!(await sendTestResponseAPI(
+					fetch,
+					code || user?.email || '',
+					user?.id || null,
+					languageTest.id,
+					currentGroup.id,
+					questions[currentQuestionId].id,
+					(new Date().getTime() - startTime) / 1000,
+					option,
+					null
+				))
+			) {
+				return;
+			}
+		}
+		if (currentQuestionId < questions.length - 1) {
+			setQuestionId(currentQuestionId + 1);
+			startTime = new Date().getTime();
+		} else {
+			nextGroup();
+		}
+	}
+</script>
+
+<div class="text-center">{nAnswers}/{languageTest.numQuestions}</div>
+
+{#if currentQuestion instanceof TestTaskQuestionGapfill && currentQuestionParts}
+	<div class="mx-auto mt-16 center flex flex-col">
+		<div>
+			{#each currentQuestionParts as part (part)}
+				{#if part.gap !== null}
+					<Gapfill length={part.text.length} onInput={(text) => (part.gap = text)} />
+				{:else}
+					{part.text}
+				{/if}
+			{/each}
+		</div>
+		<button class="button mt-8" onclick={sendGap}>{$t('button.next')}</button>
+	</div>
+{:else if currentQuestion instanceof TestTaskQuestionQcm}
+	<div class="mx-auto mt-16 text-center">
+		{#if currentQuestion.type === TestTaskQuestionQcmType.text}
+			<pre class="text-center font-bold py-4 px-6 m-auto">{currentQuestion.value}</pre>
+		{:else if currentQuestion.type === TestTaskQuestionQcmType.image}
+			<img src={currentQuestion.value} alt="Question" />
+		{:else if currentQuestion.type === TestTaskQuestionQcmType.audio}
+			<audio bind:this={soundPlayer} controls autoplay class="rounded-lg mx-auto">
+				<source src={currentQuestion.value} type="audio/mpeg" />
+				Your browser does not support the audio element.
+			</audio>
+		{/if}
+	</div>
+
+	<div class="mt-16 w-max">
+		<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
+			{#each currentQuestion.optionsRandomized as option (option)}
+				<div
+					class="h-48 w-48 overflow-hidden rounded-lg border border-black"
+					onclick={() => selectOption(option.index)}
+					role="button"
+					onkeydown={() => selectOption(option.index)}
+					tabindex="0"
+				>
+					{#if option.type === TestTaskQuestionQcmType.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"
+						>
+							{option.value}
+						</span>
+					{:else if option.type === TestTaskQuestionQcmType.image}
+						<img
+							src={option.value}
+							alt="Option {option}"
+							class="object-cover h-full w-full transition-transform duration-200 ease-in-out transform hover:scale-105"
+						/>
+					{:else if option.type === TestTaskQuestionQcmType.audio}
+						<audio
+							controls
+							class="w-full"
+							onclick={(e) => {
+								e.preventDefault();
+								e.stopPropagation();
+							}}
+						>
+							<source src={option.value} type="audio/mpeg" />
+							Your browser does not support the audio element.
+						</audio>
+					{/if}
+				</div>
+			{/each}
+		</div>
+	</div>
+{:else}
+	Nop
+{/if}
diff --git a/frontend/src/lib/types/testTaskGroups.ts b/frontend/src/lib/types/testTaskGroups.ts
index 74c89e16..1b9eadfc 100644
--- a/frontend/src/lib/types/testTaskGroups.ts
+++ b/frontend/src/lib/types/testTaskGroups.ts
@@ -5,12 +5,20 @@ export default class TestTaskGroup {
 	private _id: number;
 	private _title: string;
 	private _demo: boolean;
+	private _randomize: boolean;
 	private _questions: TestTaskQuestion[];
 
-	constructor(id: number, title: string, demo: boolean, questions: TestTaskQuestion[]) {
+	constructor(
+		id: number,
+		title: string,
+		demo: boolean,
+		randomize: boolean,
+		questions: TestTaskQuestion[]
+	) {
 		this._id = id;
 		this._title = title;
 		this._demo = demo;
+		this._randomize = randomize;
 		this._questions = questions;
 	}
 
@@ -26,6 +34,10 @@ export default class TestTaskGroup {
 		return this._demo;
 	}
 
+	get randomize(): boolean {
+		return this._randomize;
+	}
+
 	get questions(): TestTaskQuestion[] {
 		return this._questions;
 	}
@@ -36,7 +48,7 @@ export default class TestTaskGroup {
 			return null;
 		}
 		const questions = TestTaskQuestion.parseAll(data.questions);
-		return new TestTaskGroup(data.id, data.title, data.demo, questions);
+		return new TestTaskGroup(data.id, data.title, data.demo, data.randomize, questions);
 	}
 
 	static parseAll(data: any): TestTaskGroup[] {
diff --git a/frontend/src/lib/types/testTaskQuestions.ts b/frontend/src/lib/types/testTaskQuestions.ts
index 77381799..afa027fa 100644
--- a/frontend/src/lib/types/testTaskQuestions.ts
+++ b/frontend/src/lib/types/testTaskQuestions.ts
@@ -1,3 +1,5 @@
+import { shuffle } from '$lib/utils/arrays';
+
 export abstract class TestTaskQuestion {
 	private _id: number;
 	private _question: string;
@@ -38,6 +40,14 @@ export abstract class TestTaskQuestion {
 	}
 }
 
+export enum TestTaskQuestionQcmType {
+	image = 'image',
+	text = 'text',
+	audio = 'audio',
+	dropdown = 'dropdown',
+	radio = 'radio'
+}
+
 export class TestTaskQuestionQcm extends TestTaskQuestion {
 	private _options: string[];
 	private _correct: number;
@@ -48,14 +58,41 @@ export class TestTaskQuestionQcm extends TestTaskQuestion {
 		this._correct = correct;
 	}
 
-	get options(): string[] {
-		return this._options;
+	get options(): { type: string; value: string }[] {
+		return this._options.map((option) => {
+			const type = option.split(':')[0];
+			const value = option.split(':').slice(1).join(':');
+			return { type, value };
+		});
+	}
+
+	get optionsRandomized(): { type: string; value: string; index: number }[] {
+		let options = this.options.map((option, index) => ({ ...option, index }));
+		shuffle(options);
+		return options;
 	}
 
 	get correct(): number {
 		return this._correct;
 	}
 
+	get type(): TestTaskQuestionQcmType | null {
+		switch (this.question.split(':')[0]) {
+			case 'image':
+				return TestTaskQuestionQcmType.image;
+			case 'audio':
+				return TestTaskQuestionQcmType.audio;
+			case 'text':
+				return TestTaskQuestionQcmType.text;
+		}
+
+		return null;
+	}
+
+	get value(): string {
+		return this.question.split(':').slice(1).join(':');
+	}
+
 	static parse(data: any): TestTaskQuestionQcm | null {
 		if (data === null) {
 			return null;
@@ -87,6 +124,20 @@ export class TestTaskQuestionGapfill extends TestTaskQuestion {
 		return question.replace(/<[^>]+>/, '_'.repeat(this.length));
 	}
 
+	get parts(): { text: string; gap: string | null }[] {
+		const gapText = this.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;
+	}
+
 	static parse(data: any): TestTaskQuestionGapfill | null {
 		if (data === null) {
 			return null;
diff --git a/frontend/src/lib/utils/arrays.ts b/frontend/src/lib/utils/arrays.ts
new file mode 100644
index 00000000..e98fb56e
--- /dev/null
+++ b/frontend/src/lib/utils/arrays.ts
@@ -0,0 +1,12 @@
+//source: shuffle function code taken from https://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array/18650169#18650169
+export function shuffle(array: any[]) {
+	let currentIndex = array.length;
+	// While there remain elements to shuffle...
+	while (currentIndex != 0) {
+		// Pick a remaining element...
+		let randomIndex = Math.floor(Math.random() * currentIndex);
+		currentIndex--;
+		// And swap it with the current element.
+		[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
+	}
+}
diff --git a/frontend/src/routes/studies/[[id]]/+page.svelte b/frontend/src/routes/studies/[[id]]/+page.svelte
index d88936ee..3fc11cc5 100644
--- a/frontend/src/routes/studies/[[id]]/+page.svelte
+++ b/frontend/src/routes/studies/[[id]]/+page.svelte
@@ -5,6 +5,8 @@
 	import { displayDate } from '$lib/utils/date';
 	import { toastWarning } from '$lib/utils/toasts';
 	import { get } from 'svelte/store';
+	import LanguageTest from '$lib/components/tests/languageTest.svelte';
+	import { TestTask, TestTyping } from '$lib/types/tests';
 
 	let { data, form }: { data: PageData; form: FormData } = $props();
 	let study: Study | undefined = $state(data.study);
@@ -120,6 +122,11 @@
 					{$t('button.next')}
 				</button>
 			</div>
+		{:else if current_step < study.tests.length + 2}
+			{@const test = study.tests[current_step - 2]}
+			{#if test instanceof TestTask}
+				<LanguageTest languageTest={test} {user} {code} onFinish={() => current_step++} />
+			{:else if test instanceof TestTyping}{/if}
 		{/if}
 	{/if}
 </div>
diff --git a/frontend/src/routes/tests/[id]/+page.svelte b/frontend/src/routes/tests/[id]/+page.svelte
index a2c19dcb..57f71211 100644
--- a/frontend/src/routes/tests/[id]/+page.svelte
+++ b/frontend/src/routes/tests/[id]/+page.svelte
@@ -16,8 +16,6 @@
 	let { data }: { data: PageData } = $props();
 	let { user, survey }: { user: User | null; survey: Survey } = data;
 
-	let sid =
-		Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
 	let startTime = new Date().getTime();
 
 	function getSortedQuestions(group: SurveyGroup) {
-- 
GitLab


From 2594ccfb42e3153df8202647f4f86787ae840c38 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Mon, 3 Mar 2025 09:47:41 +0100
Subject: [PATCH 29/44] Shuffle & force rerender

---
 .../lib/components/tests/languageTest.svelte  | 25 +------------------
 .../src/routes/studies/[[id]]/+page.svelte    |  4 ++-
 2 files changed, 4 insertions(+), 25 deletions(-)

diff --git a/frontend/src/lib/components/tests/languageTest.svelte b/frontend/src/lib/components/tests/languageTest.svelte
index 728d5dc3..cf049dab 100644
--- a/frontend/src/lib/components/tests/languageTest.svelte
+++ b/frontend/src/lib/components/tests/languageTest.svelte
@@ -10,7 +10,6 @@
 	import type User from '$lib/types/user';
 	import Gapfill from '../surveys/gapfill.svelte';
 	import { sendTestResponseAPI } from '$lib/api/tests';
-	import { getSurveyScoreAPI } from '$lib/api/survey';
 
 	let {
 		languageTest,
@@ -30,8 +29,6 @@
 
 	let nAnswers = $state(1);
 
-	let sid =
-		Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
 	let startTime = new Date().getTime();
 
 	let currentGroupId = $state(0);
@@ -46,26 +43,9 @@
 	let currentQuestionParts = $derived(
 		currentQuestion instanceof TestTaskQuestionGapfill ? currentQuestion.parts : null
 	);
-	$inspect(currentQuestion);
 
 	let soundPlayer: HTMLAudioElement | null = $state(null);
 
-	let selectedOption: string;
-	let finalScore: number | null = $state(null);
-
-	//source: shuffle function code taken from https://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array/18650169#18650169
-	function shuffle(array: string[]) {
-		let currentIndex = array.length;
-		// While there remain elements to shuffle...
-		while (currentIndex != 0) {
-			// Pick a remaining element...
-			let randomIndex = Math.floor(Math.random() * currentIndex);
-			currentIndex--;
-			// And swap it with the current element.
-			[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
-		}
-	}
-
 	function setGroupId(id: number) {
 		currentGroupId = id;
 		if (currentGroup.id < 1100) {
@@ -84,10 +64,7 @@
 			setGroupId(currentGroupId + 1);
 			//special group id for end of survey questions
 		} else {
-			const scoreData = await getSurveyScoreAPI(fetch, languageTest.id, sid);
-			if (scoreData) {
-				finalScore = scoreData.score;
-			}
+			console.log('END');
 			onFinish();
 		}
 	}
diff --git a/frontend/src/routes/studies/[[id]]/+page.svelte b/frontend/src/routes/studies/[[id]]/+page.svelte
index 3fc11cc5..3ef05f4c 100644
--- a/frontend/src/routes/studies/[[id]]/+page.svelte
+++ b/frontend/src/routes/studies/[[id]]/+page.svelte
@@ -125,7 +125,9 @@
 		{:else if current_step < study.tests.length + 2}
 			{@const test = study.tests[current_step - 2]}
 			{#if test instanceof TestTask}
-				<LanguageTest languageTest={test} {user} {code} onFinish={() => current_step++} />
+				{#key test}
+					<LanguageTest languageTest={test} {user} {code} onFinish={() => current_step++} />
+				{/key}
 			{:else if test instanceof TestTyping}{/if}
 		{/if}
 	{/if}
-- 
GitLab


From e5b65c19133c245c1e9f339883630eba6ad6ba64 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Mon, 3 Mar 2025 20:29:48 +0100
Subject: [PATCH 30/44] Fix typings

---
 backend/app/crud/tests.py                     |   8 +-
 backend/app/models/tests.py                   | 168 +++++++++++++-----
 backend/app/routes/studies.py                 |  11 +-
 backend/app/routes/tests.py                   |  12 +-
 backend/app/schemas/tests.py                  |  36 +++-
 frontend/src/lang/fr.json                     |   3 +-
 frontend/src/lib/api/tests.ts                 |  96 +++++++---
 .../lib/components/tests/languageTest.svelte  |  10 +-
 .../src/lib/components/tests/typingbox.svelte | 110 +++++++-----
 frontend/src/lib/types/tests.ts               |  40 ++++-
 frontend/src/lib/utils/toasts.ts              |   6 +-
 .../src/routes/studies/[[id]]/+page.svelte    |  24 ++-
 scripts/surveys/groups.csv                    |   1 -
 scripts/surveys/tests_task.csv                |  10 +-
 14 files changed, 385 insertions(+), 150 deletions(-)

diff --git a/backend/app/crud/tests.py b/backend/app/crud/tests.py
index da3c9b83..ecdea2a1 100644
--- a/backend/app/crud/tests.py
+++ b/backend/app/crud/tests.py
@@ -16,7 +16,7 @@ def get_tests(db: Session, skip: int = 0) -> list[models.Test]:
     return db.query(models.Test).offset(skip).all()
 
 
-def get_test(db: Session, test_id: int) -> models.Test:
+def get_test(db: Session, test_id: int) -> models.Test | None:
     return db.query(models.Test).filter(models.Test.id == test_id).first()
 
 
@@ -54,7 +54,7 @@ def create_group(
     return db_group
 
 
-def get_group(db: Session, group_id: int) -> models.TestTaskGroup:
+def get_group(db: Session, group_id: int) -> models.TestTaskGroup | None:
     return (
         db.query(models.TestTaskGroup)
         .filter(models.TestTaskGroup.id == group_id)
@@ -113,8 +113,8 @@ def delete_question(db: Session, question_id: int):
     return None
 
 
-def create_test_task_entry(db: Session, entry: schemas.TestTaskEntryCreate):
-    db_entry = models.TestTaskEntry(**entry.model_dump())
+def create_test_entry(db: Session, entry: schemas.TestEntryCreate):
+    db_entry = models.TestEntry(**entry.model_dump())
     db.add(db_entry)
     db.commit()
     db.refresh(db_entry)
diff --git a/backend/app/models/tests.py b/backend/app/models/tests.py
index deae27cf..5c4d933f 100644
--- a/backend/app/models/tests.py
+++ b/backend/app/models/tests.py
@@ -18,24 +18,6 @@ class TestTyping(Base):
     )
 
 
-class TestTypingEntry(Base):
-    __tablename__ = "test_typing_entries"
-
-    id = Column(Integer, primary_key=True, index=True)
-    test_id = Column(Integer, ForeignKey("test_typings.test_id"), index=True)
-    code = Column(String, nullable=True)
-    user_id = Column(Integer, ForeignKey("users.id"), default=None)
-    created_at = Column(DateTime, default=datetime_aware)
-    position = Column(Integer, nullable=False)
-    downtime = Column(Integer, nullable=False)
-    uptime = Column(Integer, nullable=False)
-    key_code = Column(Integer, nullable=False)
-    key_value = Column(String, nullable=False)
-
-    test_typing = relationship("TestTyping")
-    user = relationship("User")
-
-
 class TestTask(Base):
     __tablename__ = "test_tasks"
     test_id = Column(Integer, ForeignKey("tests.id"), primary_key=True)
@@ -72,12 +54,20 @@ class Test(Base):
     @validates("test_typing")
     def adjust_test_typing(self, _, value) -> TestTyping | None:
         if value:
-            return TestTyping(**value, test_id=self.id)
+            if isinstance(value, dict):
+                return TestTyping(**value, test_id=self.id)
+            else:
+                value.test_id = self.id
+                return value
 
     @validates("test_task")
     def adjust_test_task(self, _, value) -> TestTask | None:
         if value:
-            return TestTask(**value, test_id=self.id)
+            if isinstance(value, dict):
+                return TestTask(**value, test_id=self.id)
+            else:
+                value.test_id = self.id
+                return value
 
 
 class TestTaskTaskGroup(Base):
@@ -145,56 +135,138 @@ class TestTaskQuestion(Base):
     @validates("question_qcm")
     def adjust_question_qcm(self, _, value) -> TestTaskQuestionQCM | None:
         if value:
-            return TestTaskQuestionQCM(**value, question_id=self.id)
+            if isinstance(value, dict):
+                return TestTaskQuestionQCM(**value, question_id=self.id)
+            else:
+                value.question_id = self.id
+                return value
 
 
-class TestTaskEntryQCM(Base):
-    __tablename__ = "test_task_entries_qcm"
+class TestEntryTaskQCM(Base):
+    __tablename__ = "test_entries_task_qcm"
 
-    entry_id = Column(Integer, ForeignKey("test_task_entries.id"), primary_key=True)
+    entry_id = Column(
+        Integer, ForeignKey("test_entries_task.entry_id"), primary_key=True
+    )
     selected_id = Column(Integer, nullable=False)
 
-    entry = relationship(
-        "TestTaskEntry", uselist=False, back_populates="entry_qcm", lazy="selectin"
+    entry_task = relationship(
+        "TestEntryTask",
+        uselist=False,
+        back_populates="entry_task_qcm",
+        lazy="selectin",
     )
 
 
-class TestTaskEntryGapfill(Base):
-    __tablename__ = "test_task_entries_gapfill"
+class TestEntryTaskGapfill(Base):
+    __tablename__ = "test_entries_task_gapfill"
 
-    entry_id = Column(Integer, ForeignKey("test_task_entries.id"), primary_key=True)
+    entry_id = Column(
+        Integer, ForeignKey("test_entries_task.entry_id"), primary_key=True, index=True
+    )
     text = Column(String, nullable=False)
 
-    entry = relationship(
-        "TestTaskEntry", uselist=False, back_populates="entry_gapfill", lazy="selectin"
+    entry_task = relationship(
+        "TestEntryTask",
+        uselist=False,
+        back_populates="entry_task_gapfill",
+        lazy="selectin",
     )
 
 
-class TestTaskEntry(Base):
-    __tablename__ = "test_task_entries"
+class TestEntryTask(Base):
+    __tablename__ = "test_entries_task"
+
+    entry_id = Column(
+        Integer, ForeignKey("test_entries.id"), primary_key=True, index=True
+    )
 
-    id = Column(Integer, primary_key=True, index=True)
-    code = Column(String, nullable=True)
-    user_id = Column(Integer, ForeignKey("users.id"), default=None)
-    created_at = Column(DateTime, default=datetime_aware)
-    test_task_id = Column(Integer, ForeignKey("test_tasks.test_id"), index=True)
     test_group_id = Column(Integer, ForeignKey("test_task_groups.id"), index=True)
     test_question_id = Column(Integer, ForeignKey("test_task_questions.id"), index=True)
     response_time = Column(Float, nullable=False)
 
-    entry_qcm = relationship(
-        "TestTaskEntryQCM", uselist=False, back_populates="entry", lazy="selectin"
+    entry_task_qcm = relationship(
+        "TestEntryTaskQCM",
+        uselist=False,
+        back_populates="entry_task",
+        lazy="selectin",
     )
-    entry_gapfill = relationship(
-        "TestTaskEntryGapfill", uselist=False, back_populates="entry", lazy="selectin"
+    entry_task_gapfill = relationship(
+        "TestEntryTaskGapfill",
+        uselist=False,
+        back_populates="entry_task",
+        lazy="selectin",
     )
 
-    @validates("entry_qcm")
-    def adjust_entry_qcm(self, _, value) -> TestTaskEntryQCM | None:
+    entry = relationship("TestEntry", uselist=False, back_populates="entry_task")
+
+    @validates("entry_task_qcm")
+    def adjust_entry_qcm(self, _, value) -> TestEntryTaskQCM | None:
         if value:
-            return TestTaskEntryQCM(**value, entry_id=self.id)
+            if isinstance(value, dict):
+                return TestEntryTaskQCM(**value, entry_id=self.entry_id)
+            else:
+                value.entry_id = self.entry_id
+                return value
+
+    @validates("entry_task_gapfill")
+    def adjust_entry_gapfill(self, _, value) -> TestEntryTaskGapfill | None:
+        if value:
+            if isinstance(value, dict):
+                return TestEntryTaskGapfill(**value, entry_id=self.entry_id)
+            else:
+                value.entry_id = self.entry_id
+                return value
+
+
+class TestEntryTyping(Base):
+    __tablename__ = "test_entries_typing"
+
+    entry_id = Column(
+        Integer, ForeignKey("test_entries.id"), primary_key=True, index=True
+    )
+
+    position = Column(Integer, nullable=False)
+    downtime = Column(Integer, nullable=False)
+    uptime = Column(Integer, nullable=False)
+    key_code = Column(Integer, nullable=False)
+    key_value = Column(String, nullable=False)
 
-    @validates("entry_gapfill")
-    def adjust_entry_gapfill(self, _, value) -> TestTaskEntryGapfill | None:
+    entry = relationship(
+        "TestEntry", uselist=False, back_populates="entry_typing", lazy="selectin"
+    )
+
+
+class TestEntry(Base):
+    __tablename__ = "test_entries"
+
+    id = Column(Integer, primary_key=True, index=True)
+    code = Column(String, nullable=False)
+    user_id = Column(Integer, ForeignKey("users.id"), default=None, nullable=True)
+    test_id = Column(Integer, ForeignKey("tests.id"), nullable=False)
+    created_at = Column(DateTime, default=datetime_aware)
+
+    entry_task = relationship(
+        "TestEntryTask", uselist=False, back_populates="entry", lazy="selectin"
+    )
+    entry_typing = relationship(
+        "TestEntryTyping", uselist=False, back_populates="entry", lazy="selectin"
+    )
+
+    @validates("entry_task")
+    def adjust_entry_task(self, _, value) -> TestEntryTask | None:
+        if value:
+            if isinstance(value, dict):
+                return TestEntryTask(**value, entry_id=self.id)
+            else:
+                value.entry_id = self.id
+                return value
+
+    @validates("entry_typing")
+    def adjust_entry_typing(self, _, value) -> TestEntryTyping | None:
         if value:
-            return TestTaskEntryGapfill(**value, entry_id=self.id)
+            if isinstance(value, dict):
+                return TestEntryTyping(**value, entry_id=self.id)
+            else:
+                value.entry_id = self.id
+                return value
diff --git a/backend/app/routes/studies.py b/backend/app/routes/studies.py
index ea6b7ae9..f204389d 100644
--- a/backend/app/routes/studies.py
+++ b/backend/app/routes/studies.py
@@ -1,4 +1,4 @@
-from fastapi import APIRouter, Depends, status
+from fastapi import APIRouter, Depends, HTTPException, status
 
 import crud
 import schemas
@@ -32,7 +32,14 @@ def get_study(
     study_id: int,
     db: Session = Depends(get_db),
 ):
-    return crud.get_study(db, study_id)
+    study = crud.get_study(db, study_id)
+
+    if study is None:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail="Study not found"
+        )
+
+    return study
 
 
 @require_admin("You do not have permission to patch a study.")
diff --git a/backend/app/routes/tests.py b/backend/app/routes/tests.py
index bb23b3a1..b693445c 100644
--- a/backend/app/routes/tests.py
+++ b/backend/app/routes/tests.py
@@ -31,7 +31,13 @@ def get_test(
     test_id: int,
     db: Session = Depends(get_db),
 ):
-    return crud.get_test(db, test_id)
+    test = crud.get_test(db, test_id)
+    if test is None:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail="Test not found"
+        )
+
+    return test
 
 
 @require_admin("You do not have permission to: delete a test.")
@@ -179,7 +185,7 @@ def delete_question(
 
 @testRouter.post("/entries", status_code=status.HTTP_201_CREATED)
 def create_entry(
-    entry: schemas.TestTaskEntryCreate,
+    entry: schemas.TestEntryCreate,
     db: Session = Depends(get_db),
 ):
-    return crud.create_test_task_entry(db, entry).id
+    return crud.create_test_entry(db, entry).id
diff --git a/backend/app/schemas/tests.py b/backend/app/schemas/tests.py
index 63f6ad4f..99efdae3 100644
--- a/backend/app/schemas/tests.py
+++ b/backend/app/schemas/tests.py
@@ -125,20 +125,42 @@ class TestTaskEntryGapfillCreate(BaseModel):
 
 
 class TestTaskEntryCreate(BaseModel):
-    code: str
-    user_id: int
-    test_task_id: int
     test_group_id: int
     test_question_id: int
     response_time: float
 
-    entry_qcm: TestTaskEntryQCMCreate | None = None
-    entry_gapfill: TestTaskEntryGapfillCreate | None = None
+    entry_task_qcm: TestTaskEntryQCMCreate | None = None
+    entry_task_gapfill: TestTaskEntryGapfillCreate | None = None
 
     @model_validator(mode="after")
     def check_entry_type(self) -> Self:
-        if self.entry_qcm is None and self.entry_gapfill is None:
+        if self.entry_task_qcm is None and self.entry_task_gapfill is None:
             raise ValueError("QCM or Gapfill must be provided")
-        if self.entry_qcm is not None and self.entry_gapfill is not None:
+        if self.entry_task_qcm is not None and self.entry_task_gapfill is not None:
             raise ValueError("QCM and Gapfill cannot be provided at the same time")
         return self
+
+
+class TestTypingEntryCreate(BaseModel):
+    position: int
+    downtime: int
+    uptime: int
+    key_code: int
+    key_value: str
+
+
+class TestEntryCreate(BaseModel):
+    code: str
+    user_id: int
+    test_id: int
+
+    entry_task: TestTaskEntryCreate | None = None
+    entry_typing: TestTypingEntryCreate | None = None
+
+    @model_validator(mode="after")
+    def check_entry_type(self) -> Self:
+        if self.entry_task is None and self.entry_typing is None:
+            raise ValueError("Task or Typing must be provided")
+        if self.entry_task is not None and self.entry_typing is not None:
+            raise ValueError("Task and Typing cannot be provided at the same time")
+        return self
diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json
index 1091d0b2..a8f642ff 100644
--- a/frontend/src/lang/fr.json
+++ b/frontend/src/lang/fr.json
@@ -380,7 +380,8 @@
 		"consentStudyData": "Informations sur l'étude.",
 		"tab": {
 			"study": "Étude",
-			"code": "Code"
+			"code": "Code",
+			"tests": "Tests"
 		}
 	},
 	"tests": {
diff --git a/frontend/src/lib/api/tests.ts b/frontend/src/lib/api/tests.ts
index 8af59694..154a4b9c 100644
--- a/frontend/src/lib/api/tests.ts
+++ b/frontend/src/lib/api/tests.ts
@@ -1,39 +1,93 @@
 import type { fetchType } from '$lib/utils/types';
 
-export async function sendTestResponseAPI(
+export async function sendTestEntryTaskQcmAPI(
 	fetch: fetchType,
 	code: string,
 	user_id: number | null,
-	test_task_id: number,
+	test_id: number,
 	test_group_id: number,
 	test_question_id: number,
 	response_time: number,
-	qcm_selected_id: number | null,
-	gapfill_text: string | null
+	selected_id: number
 ) {
-	const body = {
-		code,
-		user_id,
-		test_task_id,
-		test_group_id,
-		test_question_id,
-		response_time,
-		entry_qcm: null as null | { selected_id: number },
-		entry_gapfill: null as null | { text: string }
-	};
+	const response = await fetch(`/api/tests/entries`, {
+		method: 'POST',
+		headers: { 'Content-Type': 'application/json' },
+		body: JSON.stringify({
+			code,
+			user_id,
+			test_id,
+			entry_task: {
+				test_group_id,
+				test_question_id,
+				response_time,
+				entry_task_qcm: {
+					selected_id
+				}
+			}
+		})
+	});
+
+	return response.ok;
+}
 
-	if (qcm_selected_id !== null) {
-		body.entry_qcm = { selected_id: qcm_selected_id };
-	}
+export async function sendTestEntryTaskGapfillAPI(
+	fetch: fetchType,
+	code: string,
+	user_id: number | null,
+	test_id: number,
+	test_group_id: number,
+	test_question_id: number,
+	response_time: number,
+	text: string
+) {
+	const response = await fetch(`/api/tests/entries`, {
+		method: 'POST',
+		headers: { 'Content-Type': 'application/json' },
+		body: JSON.stringify({
+			code,
+			user_id,
+			test_id,
+			entry_task: {
+				test_group_id,
+				test_question_id,
+				response_time,
+				entry_task_gapfill: {
+					text
+				}
+			}
+		})
+	});
 
-	if (gapfill_text !== null) {
-		body.entry_gapfill = { text: gapfill_text };
-	}
+	return response.ok;
+}
 
+export async function sendTestEntryTypingAPI(
+	fetch: fetchType,
+	code: string,
+	user_id: number | null,
+	test_id: number,
+	position: number,
+	downtime: number,
+	uptime: number,
+	key_code: number,
+	key_value: string
+) {
 	const response = await fetch(`/api/tests/entries`, {
 		method: 'POST',
 		headers: { 'Content-Type': 'application/json' },
-		body: JSON.stringify(body)
+		body: JSON.stringify({
+			code,
+			user_id,
+			test_id,
+			entry_typing: {
+				position,
+				downtime,
+				uptime,
+				key_code,
+				key_value
+			}
+		})
 	});
 
 	return response.ok;
diff --git a/frontend/src/lib/components/tests/languageTest.svelte b/frontend/src/lib/components/tests/languageTest.svelte
index cf049dab..84610108 100644
--- a/frontend/src/lib/components/tests/languageTest.svelte
+++ b/frontend/src/lib/components/tests/languageTest.svelte
@@ -1,4 +1,5 @@
 <script lang="ts">
+	import { sendTestEntryTaskGapfillAPI, sendTestEntryTaskQcmAPI } from '$lib/api/tests';
 	import { t } from '$lib/services/i18n';
 	import type { TestTask } from '$lib/types/tests';
 	import {
@@ -9,7 +10,6 @@
 	} from '$lib/types/testTaskQuestions';
 	import type User from '$lib/types/user';
 	import Gapfill from '../surveys/gapfill.svelte';
-	import { sendTestResponseAPI } from '$lib/api/tests';
 
 	let {
 		languageTest,
@@ -81,7 +81,7 @@
 				.join('|');
 
 			if (
-				!(await sendTestResponseAPI(
+				!(await sendTestEntryTaskGapfillAPI(
 					fetch,
 					code || user?.email || '',
 					user?.id || null,
@@ -89,7 +89,6 @@
 					currentGroup.id,
 					questions[currentQuestionId].id,
 					(new Date().getTime() - startTime) / 1000,
-					null,
 					gapTexts
 				))
 			) {
@@ -107,7 +106,7 @@
 	async function selectOption(option: number) {
 		if (!currentGroup.demo) {
 			if (
-				!(await sendTestResponseAPI(
+				!(await sendTestEntryTaskQcmAPI(
 					fetch,
 					code || user?.email || '',
 					user?.id || null,
@@ -115,8 +114,7 @@
 					currentGroup.id,
 					questions[currentQuestionId].id,
 					(new Date().getTime() - startTime) / 1000,
-					option,
-					null
+					option
 				))
 			) {
 				return;
diff --git a/frontend/src/lib/components/tests/typingbox.svelte b/frontend/src/lib/components/tests/typingbox.svelte
index b6e8de11..405b9c40 100644
--- a/frontend/src/lib/components/tests/typingbox.svelte
+++ b/frontend/src/lib/components/tests/typingbox.svelte
@@ -2,27 +2,27 @@
 	import { onMount } from 'svelte';
 	import { t } from '$lib/services/i18n';
 	import config from '$lib/config';
+	import type { TestTyping } from '$lib/types/tests';
+	import { sendTestEntryTypingAPI } from '$lib/api/tests';
+	import type User from '$lib/types/user';
 
-	export let initialDuration: number;
-	export let exerciceId: number;
-	export let explications: string;
-	export let text: string;
-	export let data: {
-		exerciceId: number;
-		position: number;
-		downtime: number;
-		uptime: number;
-		keyCode: number;
-		keyValue: string;
-	}[];
-	export let inProgress = false;
-	export let onFinish: Function;
-	let duration = initialDuration >= 0 ? initialDuration : 0;
+	const {
+		typingTest,
+		onFinish,
+		user,
+		code
+	}: { typingTest: TestTyping; onFinish: Function; user: User | null; code: string | null } =
+		$props();
+
+	let duration = $state(typingTest.initialDuration);
 	let lastInput = '';
-	let input = '';
+	let input = $state('');
 	let textArea: HTMLTextAreaElement;
 	let startTime = new Date().getTime();
-	let isDone = false;
+	let isDone = $state(false);
+	let inProgress = $state(false);
+
+	let currentPressings: { [key: string]: number } = {};
 
 	onMount(async () => {
 		textArea.focus();
@@ -32,8 +32,8 @@
 		inProgress = true;
 		startTime = new Date().getTime();
 		const interval = setInterval(() => {
-			duration += initialDuration >= 0 ? -1 : 1;
-			if ((duration <= 0 && initialDuration > 0) || !inProgress) {
+			duration += typingTest.durationStep;
+			if ((duration <= 0 && typingTest.durationDirection) || !inProgress) {
 				clearInterval(interval);
 				inProgress = false;
 				isDone = true;
@@ -41,16 +41,40 @@
 			}
 		}, 1000);
 	}
+
+	async function sendTyping(
+		position: number,
+		downtime: number,
+		uptime: number,
+		key_code: number,
+		key_value: string
+	) {
+		if (
+			!(await sendTestEntryTypingAPI(
+				fetch,
+				code || user?.email || '',
+				user?.id || null,
+				typingTest.id,
+				position,
+				downtime,
+				uptime,
+				key_code,
+				key_value
+			))
+		) {
+			return;
+		}
+	}
 </script>
 
 <div class=" w-full max-w-5xl m-auto mt-8">
-	<div>{explications}</div>
+	<div>{typingTest.explainations}</div>
 	<div class="flex justify-between my-2 p-2">
 		<button
 			class="button"
-			on:click={() => {
+			onclick={() => {
 				input = '';
-				duration = initialDuration >= 0 ? initialDuration : 0;
+				duration = typingTest.initialDuration;
 				start();
 			}}
 			disabled={inProgress}
@@ -65,10 +89,10 @@
 		{#each config.SPECIAL_CHARS as char (char)}
 			<button
 				class="flex-grow"
-				on:click={() => {
+				onclick={() => {
 					input += char;
 				}}
-				on:mousedown={(e) => e.preventDefault()}
+				onmousedown={(e) => e.preventDefault()}
 			>
 				{char}
 			</button>
@@ -82,7 +106,7 @@
 		<div class="font-mono p-4 break-words">
 			<span class="text-inherit p-0 m-0 whitespace-pre-wrap break-words"
 				><!--
-			-->{#each text.slice(0, input.length) as char, i}
+			-->{#each typingTest.textRepeated.slice(0, input.length) as char, i}
 					<span
 						class="text-gray-800 p-0 m-0"
 						class:text-red-500={char !== input[i]}
@@ -92,9 +116,9 @@
 					--></span
 			><span
 				class="text-gray-400 p-0 m-0 underline decoration-2 underline-offset-6 decoration-black whitespace-pre-wrap"
-				>{text[input.length] ?? ''}</span
+				>{typingTest.textRepeated[input.length] ?? ''}</span
 			><span class="text-gray-400 p-0 m-0 whitespace-pre-wrap"
-				>{text.slice(input.length + 1) ?? ''}</span
+				>{typingTest.textRepeated.slice(input.length + 1) ?? ''}</span
 			>
 		</div>
 		<textarea
@@ -102,31 +126,35 @@
 			bind:this={textArea}
 			spellcheck="false"
 			disabled={isDone}
-			on:keyup={() => {
-				if (inProgress) {
-					data[data.length - 1].uptime = new Date().getTime() - startTime;
+			onkeyup={(e) => {
+				if (e.keyCode in currentPressings) {
+					sendTyping(
+						input.length,
+						currentPressings[e.keyCode],
+						new Date().getTime() - startTime,
+						e.keyCode,
+						e.key
+					);
+					delete currentPressings[e.keyCode];
 				}
 			}}
-			on:keydown={(e) => {
+			onkeydown={(e) => {
 				if (
 					!inProgress &&
-					((duration > 0 && initialDuration > 0) || (duration >= 0 && initialDuration < 0))
+					((duration > 0 && typingTest.durationDirection) ||
+						(duration >= 0 && typingTest.duration <= 0))
 				) {
 					start();
 				}
 				if (inProgress) {
 					lastInput = input;
 
-					data.push({
-						exerciceId,
-						position: input.length,
-						downtime: new Date().getTime() - startTime,
-						uptime: 0,
-						keyCode: e.keyCode,
-						keyValue: e.key
-					});
+					currentPressings[e.keyCode] = new Date().getTime() - startTime;
 
-					if (input === text || input.split('\n').length >= text.split('\n').length + 1) {
+					if (
+						input === typingTest.textRepeated ||
+						input.split('\n').length >= typingTest.textRepeated.split('\n').length + 1
+					) {
 						inProgress = false;
 					}
 				} else {
diff --git a/frontend/src/lib/types/tests.ts b/frontend/src/lib/types/tests.ts
index 1dc4b1ca..e71ef0a1 100644
--- a/frontend/src/lib/types/tests.ts
+++ b/frontend/src/lib/types/tests.ts
@@ -79,12 +79,21 @@ export class TestTyping extends Test {
 	private _text: string;
 	private _duration: number;
 	private _repeat: number;
-
-	constructor(id: number, title: string, text: string, duration: number, repeat: number) {
+	private _explainations: string;
+
+	constructor(
+		id: number,
+		title: string,
+		text: string,
+		duration: number,
+		repeat: number,
+		explainations: string
+	) {
 		super(id, title);
 		this._text = text;
 		this._duration = duration;
 		this._repeat = repeat;
+		this._explainations = explainations;
 	}
 
 	get text(): string {
@@ -99,6 +108,30 @@ export class TestTyping extends Test {
 		return this._repeat;
 	}
 
+	get explainations(): string {
+		return this._explainations;
+	}
+
+	get initialDuration(): number {
+		return this._duration > 0 ? this._duration : 0;
+	}
+
+	get durationDirection(): boolean {
+		return this._duration > 0;
+	}
+
+	get durationStep(): number {
+		return this.durationDirection ? -1 : 1;
+	}
+
+	get textRepeated(): string {
+		if (this._repeat === 0) {
+			return this._text.repeat(10 * this._duration);
+		} else {
+			return this._text.repeat(this._repeat);
+		}
+	}
+
 	static parse(data: any): TestTyping | null {
 		if (data === null) {
 			toastAlert('Failed to parse test data');
@@ -109,7 +142,8 @@ export class TestTyping extends Test {
 			data.title,
 			data.test_typing.text,
 			data.test_typing.duration,
-			data.test_typing.repeat
+			data.test_typing.repeat,
+			data.test_typing.explainations
 		);
 	}
 }
diff --git a/frontend/src/lib/utils/toasts.ts b/frontend/src/lib/utils/toasts.ts
index 937d963b..bade2a28 100644
--- a/frontend/src/lib/utils/toasts.ts
+++ b/frontend/src/lib/utils/toasts.ts
@@ -1,7 +1,7 @@
 import { toast } from '@zerodevx/svelte-toast';
 
 export function toastAlert(title: string, subtitle: string = '', persistant: boolean = false) {
-	if (window === undefined) return;
+	if (typeof window === 'undefined') return;
 	toast.push(`<strong>${title}</strong><br>${subtitle}`, {
 		theme: {
 			'--toastBackground': '#ff4d4f',
@@ -16,7 +16,7 @@ export function toastAlert(title: string, subtitle: string = '', persistant: boo
 }
 
 export function toastWarning(title: string, subtitle: string = '', persistant: boolean = false) {
-	if (window === undefined) return;
+	if (typeof window === 'undefined') return;
 	toast.push(`<strong>${title}</strong><br>${subtitle}`, {
 		theme: {
 			'--toastBackground': '#faad14',
@@ -31,7 +31,7 @@ export function toastWarning(title: string, subtitle: string = '', persistant: b
 }
 
 export function toastSuccess(title: string, subtitle: string = '', persistant: boolean = false) {
-	if (window === undefined) return;
+	if (typeof window === 'undefined') return;
 	toast.push(`<strong>${title}</strong><br>${subtitle}`, {
 		theme: {
 			'--toastBackground': '#52c41a',
diff --git a/frontend/src/routes/studies/[[id]]/+page.svelte b/frontend/src/routes/studies/[[id]]/+page.svelte
index 3ef05f4c..a9fc36a6 100644
--- a/frontend/src/routes/studies/[[id]]/+page.svelte
+++ b/frontend/src/routes/studies/[[id]]/+page.svelte
@@ -7,6 +7,7 @@
 	import { get } from 'svelte/store';
 	import LanguageTest from '$lib/components/tests/languageTest.svelte';
 	import { TestTask, TestTyping } from '$lib/types/tests';
+	import Typingbox from '$lib/components/tests/typingbox.svelte';
 
 	let { data, form }: { data: PageData; form: FormData } = $props();
 	let study: Study | undefined = $state(data.study);
@@ -61,7 +62,7 @@
 	</ul>
 </div>
 
-<div class="max-w-screen-md mx-auto p-5">
+<div class="max-w-screen-md min-w-max mx-auto p-5">
 	{#if current_step == 0}
 		<div class="form-control">
 			<label for="study" class="label">
@@ -124,11 +125,24 @@
 			</div>
 		{:else if current_step < study.tests.length + 2}
 			{@const test = study.tests[current_step - 2]}
-			{#if test instanceof TestTask}
-				{#key test}
+			{#key test}
+				{#if test instanceof TestTask}
 					<LanguageTest languageTest={test} {user} {code} onFinish={() => current_step++} />
-				{/key}
-			{:else if test instanceof TestTyping}{/if}
+				{:else if test instanceof TestTyping}
+					<div class="w-[1024px]">
+						<Typingbox
+							typingTest={test}
+							onFinish={() => {
+								setTimeout(() => {
+									current_step++;
+								}, 3000);
+							}}
+							{user}
+							{code}
+						/>
+					</div>
+				{/if}
+			{/key}
 		{/if}
 	{/if}
 </div>
diff --git a/scripts/surveys/groups.csv b/scripts/surveys/groups.csv
index f45d894f..3c700637 100644
--- a/scripts/surveys/groups.csv
+++ b/scripts/surveys/groups.csv
@@ -13,4 +13,3 @@ id,title,demo,options
 22,Vocabulary test T1,false,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230
 23,Vocabulary test T2,false,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245
 24,Vocabulary test T3,false,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260
-1100,basic-survey-questions,false,
\ No newline at end of file
diff --git a/scripts/surveys/tests_task.csv b/scripts/surveys/tests_task.csv
index 981ba828..0e428318 100644
--- a/scripts/surveys/tests_task.csv
+++ b/scripts/surveys/tests_task.csv
@@ -1,7 +1,7 @@
 id,title,groups
-1,Auditory Picture Vocabulary Test - English,3,1,1100
-2,APVT English Demo,3,2,1100
-3,APVT Français,10,11,12,13,14,15,1100
-4,Vocabulary test T1,20,21,22,1100
+1,Auditory Picture Vocabulary Test - English,3,1
+2,APVT English Demo,3,2
+3,APVT Français,10,11,12,13,14,15
+4,Vocabulary test T1,20,21,22
 5,Vocabulary test T2,21,23
-6,Vocabulary test T3,21,24
\ No newline at end of file
+6,Vocabulary test T3,21,24
-- 
GitLab


From 577d863ebabeaaeee072e97f4cb30367d5d2fa09 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Tue, 4 Mar 2025 12:07:28 +0100
Subject: [PATCH 31/44] DB-Diagrams

---
 docs/db-diagrams/studies-and-tests.pdf | Bin 0 -> 33101 bytes
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 docs/db-diagrams/studies-and-tests.pdf

diff --git a/docs/db-diagrams/studies-and-tests.pdf b/docs/db-diagrams/studies-and-tests.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..1ef06afd4a22fba231883afff96a2ec8d8a697e2
GIT binary patch
literal 33101
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
Ud$}TdeaBb@a9CNTRb_DgKZ%Lq`Tzg`

literal 0
HcmV?d00001

-- 
GitLab


From 6feca2a81cb21140ab3843e2739080d21fdbe066 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Tue, 4 Mar 2025 12:13:56 +0100
Subject: [PATCH 32/44] Allow non users to do studies

---
 backend/app/schemas/tests.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/backend/app/schemas/tests.py b/backend/app/schemas/tests.py
index 99efdae3..2ab5188f 100644
--- a/backend/app/schemas/tests.py
+++ b/backend/app/schemas/tests.py
@@ -151,7 +151,7 @@ class TestTypingEntryCreate(BaseModel):
 
 class TestEntryCreate(BaseModel):
     code: str
-    user_id: int
+    user_id: int | None = None
     test_id: int
 
     entry_task: TestTaskEntryCreate | None = None
-- 
GitLab


From e8ae2f60640b4732bc65752c7944fafae36f07bc Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Tue, 4 Mar 2025 12:46:16 +0100
Subject: [PATCH 33/44] End of study page

---
 frontend/src/lang/fr.json                     |  6 ++-
 .../src/routes/studies/[[id]]/+page.svelte    | 52 ++++++++++++++++++-
 2 files changed, 55 insertions(+), 3 deletions(-)

diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json
index a8f642ff..3ab16120 100644
--- a/frontend/src/lang/fr.json
+++ b/frontend/src/lang/fr.json
@@ -381,8 +381,10 @@
 		"tab": {
 			"study": "Étude",
 			"code": "Code",
-			"tests": "Tests"
-		}
+			"tests": "Tests",
+			"end": "Fin"
+		},
+		"complete": "Merci pour votre participation !"
 	},
 	"tests": {
 		"taskTests": "Tests de langue",
diff --git a/frontend/src/routes/studies/[[id]]/+page.svelte b/frontend/src/routes/studies/[[id]]/+page.svelte
index a9fc36a6..f271e876 100644
--- a/frontend/src/routes/studies/[[id]]/+page.svelte
+++ b/frontend/src/routes/studies/[[id]]/+page.svelte
@@ -59,10 +59,13 @@
 				{$t('studies.tab.tests')}
 			</li>
 		{/if}
+		<li class="step" class:step-primary={study && current_step >= study.tests.length + 2}>
+			{$t('studies.tab.end')}
+		</li>
 	</ul>
 </div>
 
-<div class="max-w-screen-md min-w-max mx-auto p-5">
+<div class="max-w-screen-md min-w-max mx-auto p-5 h-full">
 	{#if current_step == 0}
 		<div class="form-control">
 			<label for="study" class="label">
@@ -143,6 +146,53 @@
 					</div>
 				{/if}
 			{/key}
+		{:else if current_step == study.tests.length + 2}
+			<div class="flex flex-col h-full">
+				<div class="flex-grow text-center mt-16">
+					{$t('studies.complete')}
+				</div>
+
+				<dl class="text-sm">
+					<div class="sm:grid sm:grid-cols-3 sm:gap-4 mb-1">
+						<dt class="font-medium">{$t('register.consent.studyData.study')}</dt>
+						<dd class="text-gray-700 sm:col-span-2">
+							{study.title}
+						</dd>
+					</div>
+					<div class="sm:grid sm:grid-cols-3 sm:gap-4 mb-1">
+						<dt class="font-medium">{$t('register.consent.studyData.project')}</dt>
+						<dd class="text-gray-700 sm:col-span-2">
+							{$t('register.consent.studyData.projectD')}
+						</dd>
+					</div>
+					<div class="sm:grid sm:grid-cols-3 sm:gap-4 mb-1">
+						<dt class="font-medium">{$t('register.consent.studyData.university')}</dt>
+						<dd class="text-gray-700 sm:col-span-2">
+							{$t('register.consent.studyData.universityD')}
+						</dd>
+					</div>
+					<div class="sm:grid sm:grid-cols-3 sm:gap-4 mb-1">
+						<dt class="font-medium">{$t('register.consent.studyData.address')}</dt>
+						<dd class="text-gray-700 sm:col-span-2">
+							{$t('register.consent.studyData.addressD')}
+						</dd>
+					</div>
+					<div class="sm:grid sm:grid-cols-3 sm:gap-4 mb-1">
+						<dt class="font-medium">{$t('register.consent.studyData.person')}</dt>
+						<dd class="text-gray-700 sm:col-span-2">
+							{$t('register.consent.studyData.personD')}
+						</dd>
+					</div>
+					<div class="sm:grid sm:grid-cols-3 sm:gap-4 mb-1">
+						<dt class="font-medium">{$t('register.consent.studyData.email')}</dt>
+						<dd class="text-gray-700 sm:col-span-2">
+							<a href="mailto:{$t('register.consent.studyData.emailD')}" class="link"
+								>{$t('register.consent.studyData.emailD')}</a
+							>
+						</dd>
+					</div>
+				</dl>
+			</div>
 		{/if}
 	{/if}
 </div>
-- 
GitLab


From 4fbbf070c282cd6d24f903bddb376296306e1312 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Tue, 4 Mar 2025 15:09:38 +0100
Subject: [PATCH 34/44] Add score to end of study

---
 backend/app/crud/tests.py                     | 31 +++++++++++++++++++
 backend/app/models/tests.py                   |  3 ++
 backend/app/routes/tests.py                   |  9 ++++++
 backend/app/schemas/tests.py                  |  1 +
 backend/app/utils.py                          |  9 ++++++
 frontend/src/lang/fr.json                     |  7 ++++-
 frontend/src/lib/api/tests.ts                 | 22 +++++++++++++
 .../lib/components/tests/languageTest.svelte  |  4 +++
 .../src/lib/components/tests/typingbox.svelte | 13 ++++++--
 .../src/routes/studies/[[id]]/+page.svelte    | 21 +++++++++++--
 10 files changed, 114 insertions(+), 6 deletions(-)

diff --git a/backend/app/crud/tests.py b/backend/app/crud/tests.py
index ecdea2a1..d3297ff0 100644
--- a/backend/app/crud/tests.py
+++ b/backend/app/crud/tests.py
@@ -1,5 +1,6 @@
 from sqlalchemy.orm import Session
 
+from utils import extract_text_between_angle_bracket
 import models
 import schemas
 
@@ -119,3 +120,33 @@ def create_test_entry(db: Session, entry: schemas.TestEntryCreate):
     db.commit()
     db.refresh(db_entry)
     return db_entry
+
+
+def get_score(db: Session, rid: str):
+    db_entries = db.query(models.TestEntry).filter(models.TestEntry.rid == rid).all()
+
+    corrects = 0
+    total = 0
+
+    for entry in db_entries:
+        if entry.entry_task is None:
+            continue
+
+        total += 1
+
+        if entry.entry_task.entry_task_qcm:
+            selected_id = entry.entry_task.entry_task_qcm.selected_id
+            correct_id = entry.entry_task.test_question.question_qcm.correct
+            corrects += selected_id == correct_id
+
+        if entry.entry_task.entry_task_gapfill:
+            answer = entry.entry_task.entry_task_gapfill.text
+            correct = extract_text_between_angle_bracket(
+                entry.entry_task.test_question.question
+            )
+            corrects += answer == correct
+
+    if not total:
+        return 0
+
+    return corrects / total
diff --git a/backend/app/models/tests.py b/backend/app/models/tests.py
index 5c4d933f..bca7641e 100644
--- a/backend/app/models/tests.py
+++ b/backend/app/models/tests.py
@@ -198,6 +198,8 @@ class TestEntryTask(Base):
         lazy="selectin",
     )
 
+    test_question = relationship("TestTaskQuestion", uselist=False, lazy="selectin")
+
     entry = relationship("TestEntry", uselist=False, back_populates="entry_task")
 
     @validates("entry_task_qcm")
@@ -242,6 +244,7 @@ class TestEntry(Base):
 
     id = Column(Integer, primary_key=True, index=True)
     code = Column(String, nullable=False)
+    rid = Column(String, nullable=True)
     user_id = Column(Integer, ForeignKey("users.id"), default=None, nullable=True)
     test_id = Column(Integer, ForeignKey("tests.id"), nullable=False)
     created_at = Column(DateTime, default=datetime_aware)
diff --git a/backend/app/routes/tests.py b/backend/app/routes/tests.py
index b693445c..f310d546 100644
--- a/backend/app/routes/tests.py
+++ b/backend/app/routes/tests.py
@@ -1,5 +1,6 @@
 from fastapi import APIRouter, Depends, HTTPException, status
 from sqlalchemy.orm import Session
+from starlette.status import HTTP_200_OK
 
 import crud
 import schemas
@@ -189,3 +190,11 @@ def create_entry(
     db: Session = Depends(get_db),
 ):
     return crud.create_test_entry(db, entry).id
+
+
+@testRouter.get("/entries/score/{rid}", status_code=status.HTTP_200_OK)
+def get_score(
+    rid: str,
+    db: Session = Depends(get_db),
+):
+    return crud.get_score(db, rid)
diff --git a/backend/app/schemas/tests.py b/backend/app/schemas/tests.py
index 2ab5188f..dea6c6d7 100644
--- a/backend/app/schemas/tests.py
+++ b/backend/app/schemas/tests.py
@@ -151,6 +151,7 @@ class TestTypingEntryCreate(BaseModel):
 
 class TestEntryCreate(BaseModel):
     code: str
+    rid: str | None = None
     user_id: int | None = None
     test_id: int
 
diff --git a/backend/app/utils.py b/backend/app/utils.py
index 95b76652..09d7c588 100644
--- a/backend/app/utils.py
+++ b/backend/app/utils.py
@@ -1,4 +1,5 @@
 import datetime
+import re
 
 
 def check_user_level(user, required_level):
@@ -9,3 +10,11 @@ def check_user_level(user, required_level):
 
 def datetime_aware():
     return datetime.datetime.now().astimezone(datetime.timezone.utc)
+
+
+def extract_text_between_angle_bracket(text):
+    pattern = r"<(.*?)>"
+    match = re.search(pattern, text)
+    if match:
+        return match.group(1)
+    return None
diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json
index 3ab16120..21fa5fef 100644
--- a/frontend/src/lang/fr.json
+++ b/frontend/src/lang/fr.json
@@ -384,7 +384,12 @@
 			"tests": "Tests",
 			"end": "Fin"
 		},
-		"complete": "Merci pour votre participation !"
+		"complete": "Merci pour votre participation !",
+		"score": {
+			"title": "Résultat moyen: ",
+			"loading": "Calcul en cours...",
+			"error": "Erreur lors du calcul du score"
+		}
 	},
 	"tests": {
 		"taskTests": "Tests de langue",
diff --git a/frontend/src/lib/api/tests.ts b/frontend/src/lib/api/tests.ts
index 154a4b9c..d83d3741 100644
--- a/frontend/src/lib/api/tests.ts
+++ b/frontend/src/lib/api/tests.ts
@@ -3,6 +3,7 @@ import type { fetchType } from '$lib/utils/types';
 export async function sendTestEntryTaskQcmAPI(
 	fetch: fetchType,
 	code: string,
+	rid: string | null,
 	user_id: number | null,
 	test_id: number,
 	test_group_id: number,
@@ -15,6 +16,7 @@ export async function sendTestEntryTaskQcmAPI(
 		headers: { 'Content-Type': 'application/json' },
 		body: JSON.stringify({
 			code,
+			rid,
 			user_id,
 			test_id,
 			entry_task: {
@@ -34,6 +36,7 @@ export async function sendTestEntryTaskQcmAPI(
 export async function sendTestEntryTaskGapfillAPI(
 	fetch: fetchType,
 	code: string,
+	rid: string | null,
 	user_id: number | null,
 	test_id: number,
 	test_group_id: number,
@@ -46,6 +49,7 @@ export async function sendTestEntryTaskGapfillAPI(
 		headers: { 'Content-Type': 'application/json' },
 		body: JSON.stringify({
 			code,
+			rid,
 			user_id,
 			test_id,
 			entry_task: {
@@ -65,6 +69,7 @@ export async function sendTestEntryTaskGapfillAPI(
 export async function sendTestEntryTypingAPI(
 	fetch: fetchType,
 	code: string,
+	rid: string | null,
 	user_id: number | null,
 	test_id: number,
 	position: number,
@@ -78,6 +83,7 @@ export async function sendTestEntryTypingAPI(
 		headers: { 'Content-Type': 'application/json' },
 		body: JSON.stringify({
 			code,
+			rid,
 			user_id,
 			test_id,
 			entry_typing: {
@@ -92,3 +98,19 @@ export async function sendTestEntryTypingAPI(
 
 	return response.ok;
 }
+
+export async function getTestEntriesScoreAPI(
+	fetch: fetchType,
+	rid: string
+): Promise<number | null> {
+	const response = await fetch(`/api/tests/entries/score/${rid}`);
+
+	if (!response.ok) return null;
+	const scoreText = await response.text();
+
+	const score = parseFloat(scoreText);
+
+	if (isNaN(score)) return null;
+
+	return score;
+}
diff --git a/frontend/src/lib/components/tests/languageTest.svelte b/frontend/src/lib/components/tests/languageTest.svelte
index 84610108..592f8dc3 100644
--- a/frontend/src/lib/components/tests/languageTest.svelte
+++ b/frontend/src/lib/components/tests/languageTest.svelte
@@ -15,11 +15,13 @@
 		languageTest,
 		user,
 		code,
+		rid,
 		onFinish = () => {}
 	}: {
 		languageTest: TestTask;
 		user: User | null;
 		code: string | null;
+		rid: string | null;
 		onFinish: Function;
 	} = $props();
 
@@ -84,6 +86,7 @@
 				!(await sendTestEntryTaskGapfillAPI(
 					fetch,
 					code || user?.email || '',
+					rid,
 					user?.id || null,
 					languageTest.id,
 					currentGroup.id,
@@ -109,6 +112,7 @@
 				!(await sendTestEntryTaskQcmAPI(
 					fetch,
 					code || user?.email || '',
+					rid,
 					user?.id || null,
 					languageTest.id,
 					currentGroup.id,
diff --git a/frontend/src/lib/components/tests/typingbox.svelte b/frontend/src/lib/components/tests/typingbox.svelte
index 405b9c40..9bdd821c 100644
--- a/frontend/src/lib/components/tests/typingbox.svelte
+++ b/frontend/src/lib/components/tests/typingbox.svelte
@@ -10,9 +10,15 @@
 		typingTest,
 		onFinish,
 		user,
-		code
-	}: { typingTest: TestTyping; onFinish: Function; user: User | null; code: string | null } =
-		$props();
+		code,
+		rid
+	}: {
+		typingTest: TestTyping;
+		onFinish: Function;
+		user: User | null;
+		code: string | null;
+		rid: string | null;
+	} = $props();
 
 	let duration = $state(typingTest.initialDuration);
 	let lastInput = '';
@@ -53,6 +59,7 @@
 			!(await sendTestEntryTypingAPI(
 				fetch,
 				code || user?.email || '',
+				rid,
 				user?.id || null,
 				typingTest.id,
 				position,
diff --git a/frontend/src/routes/studies/[[id]]/+page.svelte b/frontend/src/routes/studies/[[id]]/+page.svelte
index f271e876..fdb11ecc 100644
--- a/frontend/src/routes/studies/[[id]]/+page.svelte
+++ b/frontend/src/routes/studies/[[id]]/+page.svelte
@@ -8,11 +8,14 @@
 	import LanguageTest from '$lib/components/tests/languageTest.svelte';
 	import { TestTask, TestTyping } from '$lib/types/tests';
 	import Typingbox from '$lib/components/tests/typingbox.svelte';
+	import { getTestEntriesScoreAPI } from '$lib/api/tests';
 
 	let { data, form }: { data: PageData; form: FormData } = $props();
 	let study: Study | undefined = $state(data.study);
 	let studies: Study[] | undefined = $state(data.studies);
 	let user = $state(data.user);
+	let rid =
+		Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
 
 	let selectedStudy: Study | undefined = $state();
 
@@ -130,7 +133,7 @@
 			{@const test = study.tests[current_step - 2]}
 			{#key test}
 				{#if test instanceof TestTask}
-					<LanguageTest languageTest={test} {user} {code} onFinish={() => current_step++} />
+					<LanguageTest languageTest={test} {user} {code} {rid} onFinish={() => current_step++} />
 				{:else if test instanceof TestTyping}
 					<div class="w-[1024px]">
 						<Typingbox
@@ -142,6 +145,7 @@
 							}}
 							{user}
 							{code}
+							{rid}
 						/>
 					</div>
 				{/if}
@@ -149,7 +153,20 @@
 		{:else if current_step == study.tests.length + 2}
 			<div class="flex flex-col h-full">
 				<div class="flex-grow text-center mt-16">
-					{$t('studies.complete')}
+					<span>{$t('studies.complete')}</span>
+
+					<div class="mt-4">
+						{$t('studies.score.title')}
+						{#await getTestEntriesScoreAPI(fetch, rid)}
+							{$t('studies.score.loading')}
+						{:then score}
+							{#if score !== null}
+								{(score * 100).toFixed(0)}%
+							{:else}
+								{$t('studies.score.error')}
+							{/if}
+						{/await}
+					</div>
 				</div>
 
 				<dl class="text-sm">
-- 
GitLab


From cc2181d53d7a5fc6988e24de11210950642f2185 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Tue, 4 Mar 2025 16:52:27 +0100
Subject: [PATCH 35/44] Larger text

---
 frontend/src/lib/components/surveys/gapfill.svelte    | 2 +-
 frontend/src/lib/components/tests/languageTest.svelte | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/frontend/src/lib/components/surveys/gapfill.svelte b/frontend/src/lib/components/surveys/gapfill.svelte
index 5dbe48ec..29235102 100644
--- a/frontend/src/lib/components/surveys/gapfill.svelte
+++ b/frontend/src/lib/components/surveys/gapfill.svelte
@@ -4,7 +4,7 @@
 	let content: string = '';
 </script>
 
-<span class="relative text-blue-500 font-mono tracking-widest px-1"
+<span class="relative text-blue-500 font-mono tracking-[0.2em] px-1"
 	><!--
 	--><input
 		class="absolute bg-transparent text-transparent w-full caret-blue-500 focus:outline-none focus:ring-0"
diff --git a/frontend/src/lib/components/tests/languageTest.svelte b/frontend/src/lib/components/tests/languageTest.svelte
index 592f8dc3..721cf917 100644
--- a/frontend/src/lib/components/tests/languageTest.svelte
+++ b/frontend/src/lib/components/tests/languageTest.svelte
@@ -136,7 +136,7 @@
 <div class="text-center">{nAnswers}/{languageTest.numQuestions}</div>
 
 {#if currentQuestion instanceof TestTaskQuestionGapfill && currentQuestionParts}
-	<div class="mx-auto mt-16 center flex flex-col">
+	<div class="mx-auto mt-16 center flex flex-col text-xl">
 		<div>
 			{#each currentQuestionParts as part (part)}
 				{#if part.gap !== null}
-- 
GitLab


From b0b6d3bd2ce883bee38e16d70dc4da314c1a3610 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Tue, 4 Mar 2025 16:59:09 +0100
Subject: [PATCH 36/44] Remove old tests

---
 frontend/src/routes/tests/[id]/+page.svelte   | 460 ------------------
 frontend/src/routes/tests/[id]/+page.ts       |  24 -
 .../src/routes/tests/typing/+page.server.ts   |   0
 frontend/src/routes/tests/typing/+page.svelte |  15 -
 4 files changed, 499 deletions(-)
 delete mode 100644 frontend/src/routes/tests/[id]/+page.svelte
 delete mode 100644 frontend/src/routes/tests/[id]/+page.ts
 delete mode 100644 frontend/src/routes/tests/typing/+page.server.ts
 delete mode 100644 frontend/src/routes/tests/typing/+page.svelte

diff --git a/frontend/src/routes/tests/[id]/+page.svelte b/frontend/src/routes/tests/[id]/+page.svelte
deleted file mode 100644
index 57f71211..00000000
--- a/frontend/src/routes/tests/[id]/+page.svelte
+++ /dev/null
@@ -1,460 +0,0 @@
-<script lang="ts">
-	import { sendSurveyResponseAPI, sendSurveyResponseInfoAPI } from '$lib/api/survey';
-	import { getSurveyScoreAPI } from '$lib/api/survey';
-	import { t } from '$lib/services/i18n';
-	import { toastWarning } from '$lib/utils/toasts.js';
-	import { get } from 'svelte/store';
-	import type SurveyGroup from '$lib/types/surveyGroup';
-	import Gapfill from '$lib/components/surveys/gapfill.svelte';
-	import type { PageData } from './$types';
-	import Consent from '$lib/components/surveys/consent.svelte';
-	import Dropdown from '$lib/components/surveys/dropdown.svelte';
-	import config from '$lib/config';
-	import type User from '$lib/types/user';
-	import type Survey from '$lib/types/survey';
-
-	let { data }: { data: PageData } = $props();
-	let { user, survey }: { user: User | null; survey: Survey } = data;
-
-	let startTime = new Date().getTime();
-
-	function getSortedQuestions(group: SurveyGroup) {
-		return group.questions.sort(() => Math.random() - 0.5);
-	}
-
-	let step = $state(user ? 2 : 0);
-	let uid = $state(user?.id || null);
-	let code = $state('');
-	let subStep = $state(0);
-
-	let currentGroupId = $state(0);
-	survey.groups.sort((a, b) => {
-		if (a.demo === b.demo) {
-			return 0;
-		}
-		return a.demo ? -1 : 1;
-	});
-	let currentGroup = $derived(survey.groups[currentGroupId]);
-	let questionsRandomized = $derived(getSortedQuestions(currentGroup));
-	let currentQuestionId = $state(0);
-	let currentQuestion = $derived(questionsRandomized[currentQuestionId]);
-	let type = $derived(currentQuestion?.question.split(':')[0]);
-	let value = $derived(currentQuestion?.question.split(':').slice(1).join(':'));
-	let gaps = $derived(type === 'gap' ? gapParts(currentQuestion.question) : null);
-	let soundPlayer: HTMLAudioElement;
-	let displayQuestionOptions: string[] = $derived(
-		(() => {
-			let d = [...(currentQuestion?.options ?? [])];
-			shuffle(d);
-			return d;
-		})()
-	);
-	let finalScore: number | null = $state(null);
-	let selectedOption: string;
-	let endSurveyAnswers: { [key: string]: any } = {};
-
-	//source: shuffle function code taken from https://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array/18650169#18650169
-	function shuffle(array: string[]) {
-		let currentIndex = array.length;
-		// While there remain elements to shuffle...
-		while (currentIndex != 0) {
-			// Pick a remaining element...
-			let randomIndex = Math.floor(Math.random() * currentIndex);
-			currentIndex--;
-			// And swap it with the current element.
-			[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
-		}
-	}
-
-	function setGroupId(id: number) {
-		currentGroupId = id;
-		if (currentGroup.id < 1100) {
-			setQuestionId(0);
-		}
-	}
-
-	function setQuestionId(id: number) {
-		currentQuestionId = id;
-		if (soundPlayer) soundPlayer.load();
-	}
-
-	async function selectOption(option: string) {
-		if (!currentGroup.demo) {
-			if (
-				!(await sendSurveyResponseAPI(
-					fetch,
-					code,
-					sid,
-					uid,
-					survey.id,
-					currentGroup.id,
-					questionsRandomized[currentQuestionId].id,
-					currentQuestion.options.findIndex((o: string) => o === option) + 1,
-					(new Date().getTime() - startTime) / 1000
-				))
-			) {
-				return;
-			}
-		}
-		if (currentQuestionId < questionsRandomized.length - 1) {
-			setQuestionId(currentQuestionId + 1);
-			startTime = new Date().getTime();
-		} else {
-			nextGroup();
-		}
-	}
-
-	async function sendGap() {
-		if (!gaps) return;
-		if (!currentGroup.demo) {
-			const gapTexts = gaps
-				.filter((part) => part.gap !== null)
-				.map((part) => part.gap)
-				.join('|');
-
-			if (
-				!(await sendSurveyResponseAPI(
-					fetch,
-					code,
-					sid,
-					uid,
-					survey.id,
-					currentGroup.id,
-					questionsRandomized[currentQuestionId].id,
-					-1,
-					(new Date().getTime() - startTime) / 1000,
-					gapTexts
-				))
-			) {
-				return;
-			}
-		}
-		if (currentQuestionId < questionsRandomized.length - 1) {
-			setQuestionId(currentQuestionId + 1);
-			startTime = new Date().getTime();
-		} else {
-			nextGroup();
-		}
-	}
-
-	async function nextGroup() {
-		if (currentGroupId < survey.groups.length - 1) {
-			setGroupId(currentGroupId + 1);
-			//special group id for end of survey questions
-			if (currentGroup.id >= 1100) {
-				const scoreData = await getSurveyScoreAPI(fetch, survey.id, sid);
-				if (scoreData) {
-					finalScore = scoreData.score;
-				}
-				step += user ? 2 : 1;
-				return;
-			}
-		} else {
-			const scoreData = await getSurveyScoreAPI(fetch, survey.id, sid);
-			if (scoreData) {
-				finalScore = scoreData.score;
-			}
-			step += 2;
-		}
-	}
-
-	function checkCode() {
-		if (!code) {
-			toastWarning(get(t)('surveys.invalidCode'));
-			return;
-		}
-		if (code.length < 3) {
-			toastWarning(get(t)('surveys.invalidCode'));
-			return;
-		}
-
-		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;
-	}
-
-	async function selectAnswer(selection: string, option: string) {
-		endSurveyAnswers[selection] = option;
-		subStep += 1;
-		if (subStep == 5) {
-			await sendSurveyResponseInfoAPI(
-				fetch,
-				survey.id,
-				sid,
-				endSurveyAnswers.birthYear,
-				endSurveyAnswers.gender,
-				endSurveyAnswers.primaryLanguage,
-				endSurveyAnswers.other_language,
-				endSurveyAnswers.education
-			);
-			step += 1;
-		}
-		selectedOption = '';
-		return;
-	}
-</script>
-
-{#if step == 0}
-	<div class="max-w-screen-md mx-auto p-20 flex flex-col items-center min-h-screen">
-		<h2 class="mb-10 text-xl text-center">{survey.title}</h2>
-		<p class="mb-4 text-lg font-semibold">{$t('surveys.code')}</p>
-		<p class="mb-6 text-sm text-gray-600 text-center">{@html $t('surveys.codeIndication')}</p>
-		<input
-			type="text"
-			placeholder="Code"
-			class="input block mx-auto w-full max-w-xs border border-gray-300 rounded-md py-2 px-3 text-center"
-			onkeydown={(e) => e.key === 'Enter' && checkCode()}
-			bind:value={code}
-		/>
-		<button
-			class="button mt-4 block bg-yellow-500 text-white rounded-md py-2 px-6 hover:bg-yellow-600 transition"
-			onclick={checkCode}
-		>
-			{$t('button.next')}
-		</button>
-	</div>
-{:else if step == 1}
-	<div class="max-w-screen-md mx-auto p-5">
-		<Consent
-			introText={$t('surveys.consent.intro')}
-			participation={$t('surveys.consent.participation')}
-			participationD={$t('surveys.consent.participationD')}
-			privacy={$t('surveys.consent.privacy')}
-			privacyD={$t('surveys.consent.privacyD')}
-			rights={$t('surveys.consent.rights')}
-		/>
-		<div class="form-control">
-			<button class="button mt-4" onclick={() => step++}>
-				{$t('surveys.consent.ok')}
-			</button>
-		</div>
-	</div>
-{:else if step == 2}
-	{#if currentGroup.demo}
-		<div class="mx-auto mt-10 text-center">
-			<p class="text-center font-bold text-xl m-auto">{$t('surveys.example')}</p>
-		</div>
-	{/if}
-	{#if type == 'gap' && gaps}
-		<div class="mx-auto mt-16 center flex flex-col">
-			<div>
-				{#each gaps as part (part)}
-					{#if part.gap !== null}
-						<Gapfill length={part.text.length} onInput={(text) => (part.gap = text)} />
-					{:else}
-						{part.text}
-					{/if}
-				{/each}
-			</div>
-			<button class="button mt-8" onclick={sendGap}>{$t('button.next')}</button>
-		</div>
-	{:else}
-		<div class="mx-auto mt-16 text-center">
-			{#if type == 'text'}
-				<pre class="text-center font-bold py-4 px-6 m-auto">{value}</pre>
-			{:else if type == 'image'}
-				<img src={value} alt="Question" />
-			{:else if type == 'audio'}
-				<audio bind:this={soundPlayer} 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="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
-				{#each displayQuestionOptions as option, i (option)}
-					{@const type = option.split(':')[0]}
-					{#if type == 'dropdown'}
-						{@const value = option.split(':')[1].split(', ')}
-						<select
-							class="select select-bordered !ml-0"
-							id="dropdown"
-							name="dropdown"
-							bind:value={displayQuestionOptions[i]}
-							onchange={() => selectOption(option)}
-							required
-						>
-							{#each value as op}
-								<option value={op}>{op}</option>
-							{/each}
-						</select>
-					{:else if type == 'radio'}
-						{@const value = option.split(':')[1].split(', ')}
-						{#each value as op}
-							<label class="radio-label">
-								<input
-									type="radio"
-									name="dropdown"
-									value={op}
-									onchange={() => selectOption(op)}
-									required
-									class="radio-button"
-								/>
-								{op}
-							</label>
-						{/each}
-					{:else}
-						{@const value = option.split(':').slice(1).join(':')}
-						<div
-							class="h-48 w-48 overflow-hidden rounded-lg border border-black"
-							onclick={() => selectOption(option)}
-							role="button"
-							onkeydown={() => 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"
-									onclick={(e) => {
-										e.preventDefault();
-										e.stopPropagation();
-									}}
-								>
-									<source src={value} type="audio/mpeg" />
-									Your browser does not support the audio element.
-								</audio>
-							{/if}
-						</div>
-					{/if}
-				{/each}
-			</div>
-		</div>
-	{/if}
-{:else if step === 3}
-	{#if currentGroup.id === 1100}
-		{@const genderOptions = [
-			{ value: 'male', label: $t('surveys.genders.male') },
-			{ value: 'female', label: $t('surveys.genders.female') },
-			{ value: 'other', label: $t('surveys.genders.other') },
-			{ value: 'na', label: $t('surveys.genders.na') }
-		]}
-		{#if subStep === 0}
-			<div class="mx-auto mt-16 text-center px-4">
-				<p class="text-center font-bold py-4 px-6 m-auto">{$t('surveys.birthYear')}</p>
-				<Dropdown
-					values={Array.from({ length: 82 }, (_, i) => {
-						const year = 1931 + i;
-						return { value: year, display: year };
-					}).reverse()}
-					bind:option={selectedOption}
-					placeholder={$t('surveys.birthYear')}
-					funct={() => selectAnswer('birthYear', selectedOption)}
-				></Dropdown>
-			</div>
-		{:else if subStep === 1}
-			<div class="mx-auto mt-16 text-center px-4">
-				<p class="text-center font-bold py-4 px-6 m-auto">{$t('surveys.gender')}</p>
-				<div class="flex flex-col items-center space-y-4">
-					{#each genderOptions as { value, label }}
-						<label class="radio-label flex items-center space-x-2">
-							<input
-								type="radio"
-								name="gender"
-								{value}
-								onchange={() => selectAnswer('gender', value)}
-								required
-								class="radio-button"
-							/>
-							<span>{label}</span>
-						</label>
-					{/each}
-				</div>
-			</div>
-		{:else if subStep === 2}
-			<div class="mx-auto mt-16 text-center px-4">
-				<p class="text-center font-bold py-4 px-6 m-auto">{$t('surveys.homeLanguage')}</p>
-				<Dropdown
-					values={Object.entries(config.PRIMARY_LANGUAGE).map(([code, name]) => ({
-						value: code,
-						display: name
-					}))}
-					bind:option={selectedOption}
-					placeholder={$t('surveys.homeLanguage')}
-					funct={() => selectAnswer('primaryLanguage', selectedOption)}
-				></Dropdown>
-			</div>
-		{:else if subStep === 3}
-			<div class="mx-auto mt-16 text-center px-4">
-				<p class="text-center font-bold py-4 px-6 m-auto">{$t('surveys.otherLanguage')}</p>
-				<p class="mb-6 text-sm text-gray-600 text-center">{$t('surveys.otherLanguageNote')}</p>
-				<Dropdown
-					values={[
-						{ value: 'none', display: '/' },
-						...Object.entries(config.PRIMARY_LANGUAGE).map(([code, name]) => ({
-							value: code,
-							display: name
-						}))
-					]}
-					bind:option={selectedOption}
-					placeholder={$t('surveys.otherLanguage')}
-					funct={() => selectAnswer('other_language', selectedOption)}
-				></Dropdown>
-			</div>
-		{:else if subStep === 4}
-			<div class="mx-auto mt-16 text-center px-4">
-				<p class="text-center font-bold py-4 px-6 m-auto">{$t('surveys.education.title')}</p>
-				<Dropdown
-					values={[
-						{ value: 'NoEducation', display: $t('surveys.education.NoEducation') },
-						{ value: 'PrimarySchool', display: $t('surveys.education.PrimarySchool') },
-						{ value: 'SecondarySchool', display: $t('surveys.education.SecondarySchool') },
-						{ value: 'NonUni', display: $t('surveys.education.NonUni') },
-						{ value: 'Bachelor', display: $t('surveys.education.Bachelor') },
-						{ value: 'Master', display: $t('surveys.education.Master') }
-					]}
-					bind:option={selectedOption}
-					placeholder={$t('surveys.education.title')}
-					funct={() => selectAnswer('education', selectedOption)}
-				></Dropdown>
-			</div>
-		{/if}
-	{:else}
-		{(step += 1)}
-	{/if}
-{:else if step === 4}
-	<div class="mx-auto mt-16 text-center">
-		<h1>{$t('surveys.complete')}</h1>
-		{#if finalScore !== null}
-			<p>{$t('surveys.score')} <strong>{finalScore} %</strong></p>
-		{/if}
-	</div>
-	{#if user == null}
-		<footer class="mt-auto text-center text-xs py-4">
-			{$t('register.consent.studyData.person')}: {$t('register.consent.studyData.personD')} - {$t(
-				'register.consent.studyData.email'
-			)}:
-			<a href="mailto:{$t('register.consent.studyData.emailD')}" class="link"
-				>{$t('register.consent.studyData.emailD')}</a
-			>
-		</footer>
-	{/if}
-{/if}
diff --git a/frontend/src/routes/tests/[id]/+page.ts b/frontend/src/routes/tests/[id]/+page.ts
deleted file mode 100644
index 62c35f25..00000000
--- a/frontend/src/routes/tests/[id]/+page.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { getSurveyAPI } from '$lib/api/survey';
-import Survey from '$lib/types/survey';
-import { error, type Load } from '@sveltejs/kit';
-
-export const ssr = false;
-
-export const load: Load = async ({ params, parent, fetch }) => {
-	const { user } = await parent();
-
-	if (!params.id) return error(400, 'Invalid survey ID');
-
-	const survey_id = parseInt(params.id);
-
-	if (isNaN(survey_id)) return error(400, 'Invalid survey ID');
-
-	const survey = Survey.parse(await getSurveyAPI(fetch, survey_id));
-
-	if (!survey) return error(404, 'Survey not found');
-
-	return {
-		survey,
-		user
-	};
-};
diff --git a/frontend/src/routes/tests/typing/+page.server.ts b/frontend/src/routes/tests/typing/+page.server.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/frontend/src/routes/tests/typing/+page.svelte b/frontend/src/routes/tests/typing/+page.svelte
deleted file mode 100644
index 17ec3c91..00000000
--- a/frontend/src/routes/tests/typing/+page.svelte
+++ /dev/null
@@ -1,15 +0,0 @@
-<script lang="ts">
-	import Typingtest from '$lib/components/tests/typingtest.svelte';
-	import { t } from '$lib/services/i18n';
-
-	let finished = $state(false);
-
-	let { data } = $props();
-	let user = data.user;
-</script>
-
-{#if finished}
-	<p>{$t('surveys.complete')}</p>
-{:else}
-	<Typingtest {user} onFinish={() => (finished = true)} />
-{/if}
-- 
GitLab


From e873a5a1138721d7a5cf2541c64dc259a20300b7 Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Tue, 4 Mar 2025 17:26:58 +0100
Subject: [PATCH 37/44] beginning download studies

---
 backend/app/crud/studies.py                   | 88 +++++++++++++++++++
 backend/app/routes/studies.py                 | 12 +++
 .../src/routes/admin/sessions/+page.svelte    |  8 +-
 3 files changed, 104 insertions(+), 4 deletions(-)

diff --git a/backend/app/crud/studies.py b/backend/app/crud/studies.py
index 1c60d698..67ff512b 100644
--- a/backend/app/crud/studies.py
+++ b/backend/app/crud/studies.py
@@ -3,6 +3,11 @@ from sqlalchemy.orm import Session
 
 import models
 import schemas
+import crud
+from io import StringIO
+import csv
+from fastapi.responses import StreamingResponse
+from utils import extract_text_between_angle_bracket
 
 
 def create_study(db: Session, study: schemas.StudyCreate) -> models.Study:
@@ -70,3 +75,86 @@ def update_study(db: Session, study: schemas.StudyCreate, study_id: int) -> None
 def delete_study(db: Session, study_id: int) -> None:
     db.query(models.Study).filter(models.Study.id == study_id).delete()
     db.commit()
+
+
+def download_study(db: Session, study_id: int):
+
+    output = StringIO()
+    writer = csv.writer(output)
+
+    header = [
+        "study",
+        "test",
+        "group",
+        "item_id",
+        "user id",
+        "item type",
+        "response",
+        "correct",
+        "response time",
+    ]
+    writer.writerow(header)
+
+    # TODO filter on study_id
+    # db_entries = db.query(models.TestEntry).filter(models.TestEntry.study_id == study_id).all()
+    db_entries = db.query(models.TestEntry).all()
+
+    for entry in db_entries:
+        if entry.entry_task is None:
+            continue
+
+        test_id = entry.test_id
+        test = crud.get_test(db, test_id).title
+        group_id = entry.entry_task.test_group_id
+        group = crud.get_group(db, group_id).title
+        item = entry.entry_task.test_question_id
+        user_id = entry.user_id
+        response_time = entry.entry_task.response_time
+
+        if entry.entry_task.entry_task_qcm:
+            selected_id = entry.entry_task.entry_task_qcm.selected_id
+            correct_id = entry.entry_task.test_question.question_qcm.correct
+            correct_answer = selected_id == correct_id
+
+            item_type = "qcm"
+            row = [
+                study_id,
+                test,
+                group,
+                item,
+                user_id,
+                item_type,
+                selected_id,
+                correct_answer,
+                response_time,
+            ]
+            writer.writerow(row)
+
+        if entry.entry_task.entry_task_gapfill:
+            answer = entry.entry_task.entry_task_gapfill.text
+            correct = extract_text_between_angle_bracket(
+                entry.entry_task.test_question.question
+            )
+            correct_answer = answer == correct
+
+            item_type = "gapfill"
+            row = [
+                study_id,
+                test,
+                group,
+                item,
+                user_id,
+                item_type,
+                answer,
+                correct_answer,
+                response_time,
+            ]
+            writer.writerow(row)
+
+    output.seek(0)
+
+    return StreamingResponse(
+        output,
+        media_type="text/csv",
+        headers={"Content-Disposition": f"attachment; filename={study_id}-surveys.csv"},
+    )
diff --git a/backend/app/routes/studies.py b/backend/app/routes/studies.py
index f204389d..0cba51e4 100644
--- a/backend/app/routes/studies.py
+++ b/backend/app/routes/studies.py
@@ -59,3 +59,15 @@ def delete_study(
     db: Session = Depends(get_db),
 ):
     return crud.delete_study(db, study_id)
+
+
+@require_admin("You do not have permission to download this study.")
+@studiesRouter.get("/{study_id}/download/surveys")
+def download_study(
+    study_id: int,
+    db: Session = Depends(get_db),
+):
+    study = crud.get_study(db, study_id)
+    if study is None:
+        raise HTTPException(status_code=404, detail="Study not found")
+    return crud.download_study(db, study_id)
diff --git a/frontend/src/routes/admin/sessions/+page.svelte b/frontend/src/routes/admin/sessions/+page.svelte
index dcf1ebbd..c7153150 100644
--- a/frontend/src/routes/admin/sessions/+page.svelte
+++ b/frontend/src/routes/admin/sessions/+page.svelte
@@ -16,21 +16,21 @@
 	<a
 		class="btn btn-primary btn-sm"
 		title="Download"
-		href={`${config.API_URL}/sessions/download/messages`}
+		href={`${config.API_URL}/v1/sessions/download/messages`}
 	>
 		{$t('session.downloadAllMessages')}
 	</a>
 	<a
 		class="btn btn-primary btn-sm"
 		title="Download"
-		href={`${config.API_URL}/sessions/download/metadata`}
+		href={`${config.API_URL}/v1/sessions/download/metadata`}
 	>
 		{$t('session.downloadAllMetadata')}
 	</a>
 	<a
 		class="btn btn-primary btn-sm"
 		title="Download"
-		href={`${config.API_URL}/sessions/download/feedbacks`}
+		href={`${config.API_URL}/v1/sessions/download/feedbacks`}
 	>
 		{$t('session.downloadAllFeedbacks')}
 	</a>
@@ -74,7 +74,7 @@
 					<a
 						class="btn btn-primary btn-sm"
 						title="Download"
-						href={`${config.API_URL}/sessions/${session.id}/download/messages`}
+						href={`${config.API_URL}/v1/sessions/${session.id}/download/messages`}
 					>
 						<Icon src={ArrowDownTray} size="16" />
 					</a>
-- 
GitLab


From 0173edc3e8c2cbe471b2e030ef2be84097bb5b5d Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Tue, 4 Mar 2025 19:10:11 +0100
Subject: [PATCH 38/44] front-end download button for studies

---
 frontend/src/routes/admin/studies/+page.svelte | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/frontend/src/routes/admin/studies/+page.svelte b/frontend/src/routes/admin/studies/+page.svelte
index e9bcd1b5..5ca59fd3 100644
--- a/frontend/src/routes/admin/studies/+page.svelte
+++ b/frontend/src/routes/admin/studies/+page.svelte
@@ -3,6 +3,8 @@
 	import Study from '$lib/types/study.js';
 	import { displayDate } from '$lib/utils/date';
 	import type { PageData } from './$types';
+	import config from '$lib/config';
+	import { ArrowDownTray, Icon } from 'svelte-hero-icons';
 
 	const { data }: { data: PageData } = $props();
 
@@ -30,6 +32,15 @@
 				<td>{displayDate(study.startDate)} - {displayDate(study.endDate)}</td>
 				<td>{study.title}</td>
 				<td>{study.numberOfUsers}</td>
+				<td
+					><a
+						class="btn btn-primary btn-sm"
+						title="Download"
+						href={`${config.API_URL}/v1/studies/${study.id}/download/surveys`}
+					>
+						<Icon src={ArrowDownTray} size="16" />
+					</a></td
+				>
 				<td></td>
 			</tr>
 		{/each}
-- 
GitLab


From 462a630243ad27d6126338630a27e9edaad83528 Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Wed, 5 Mar 2025 15:54:24 +0100
Subject: [PATCH 39/44] study_id column in test entry

---
 backend/app/crud/studies.py                           | 6 +++---
 frontend/src/lib/api/tests.ts                         | 6 ++++++
 frontend/src/lib/components/tests/languageTest.svelte | 4 ++++
 frontend/src/routes/studies/[[id]]/+page.svelte       | 9 ++++++++-
 4 files changed, 21 insertions(+), 4 deletions(-)

diff --git a/backend/app/crud/studies.py b/backend/app/crud/studies.py
index 67ff512b..e13b1ca3 100644
--- a/backend/app/crud/studies.py
+++ b/backend/app/crud/studies.py
@@ -95,9 +95,9 @@ def download_study(db: Session, study_id: int):
     ]
     writer.writerow(header)
 
-    # TODO filter on study_id
-    # db_entries = db.query(models.TestEntry).filter(models.TestEntry.study_id == study_id).all()
-    db_entries = db.query(models.TestEntry).all()
+    db_entries = (
+        db.query(models.TestEntry).filter(models.TestEntry.study_id == study_id).all()
+    )
 
     for entry in db_entries:
         if entry.entry_task is None:
diff --git a/frontend/src/lib/api/tests.ts b/frontend/src/lib/api/tests.ts
index d83d3741..54a3fe1f 100644
--- a/frontend/src/lib/api/tests.ts
+++ b/frontend/src/lib/api/tests.ts
@@ -6,6 +6,7 @@ export async function sendTestEntryTaskQcmAPI(
 	rid: string | null,
 	user_id: number | null,
 	test_id: number,
+	study_id: number,
 	test_group_id: number,
 	test_question_id: number,
 	response_time: number,
@@ -19,6 +20,7 @@ export async function sendTestEntryTaskQcmAPI(
 			rid,
 			user_id,
 			test_id,
+			study_id,
 			entry_task: {
 				test_group_id,
 				test_question_id,
@@ -40,6 +42,7 @@ export async function sendTestEntryTaskGapfillAPI(
 	user_id: number | null,
 	test_id: number,
 	test_group_id: number,
+	study_id: number,
 	test_question_id: number,
 	response_time: number,
 	text: string
@@ -52,6 +55,7 @@ export async function sendTestEntryTaskGapfillAPI(
 			rid,
 			user_id,
 			test_id,
+			study_id,
 			entry_task: {
 				test_group_id,
 				test_question_id,
@@ -72,6 +76,7 @@ export async function sendTestEntryTypingAPI(
 	rid: string | null,
 	user_id: number | null,
 	test_id: number,
+	study_id: number,
 	position: number,
 	downtime: number,
 	uptime: number,
@@ -86,6 +91,7 @@ export async function sendTestEntryTypingAPI(
 			rid,
 			user_id,
 			test_id,
+			study_id,
 			entry_typing: {
 				position,
 				downtime,
diff --git a/frontend/src/lib/components/tests/languageTest.svelte b/frontend/src/lib/components/tests/languageTest.svelte
index 721cf917..cf0a2404 100644
--- a/frontend/src/lib/components/tests/languageTest.svelte
+++ b/frontend/src/lib/components/tests/languageTest.svelte
@@ -16,12 +16,14 @@
 		user,
 		code,
 		rid,
+		study_id,
 		onFinish = () => {}
 	}: {
 		languageTest: TestTask;
 		user: User | null;
 		code: string | null;
 		rid: string | null;
+		study_id: number;
 		onFinish: Function;
 	} = $props();
 
@@ -89,6 +91,7 @@
 					rid,
 					user?.id || null,
 					languageTest.id,
+					study_id,
 					currentGroup.id,
 					questions[currentQuestionId].id,
 					(new Date().getTime() - startTime) / 1000,
@@ -115,6 +118,7 @@
 					rid,
 					user?.id || null,
 					languageTest.id,
+					study_id,
 					currentGroup.id,
 					questions[currentQuestionId].id,
 					(new Date().getTime() - startTime) / 1000,
diff --git a/frontend/src/routes/studies/[[id]]/+page.svelte b/frontend/src/routes/studies/[[id]]/+page.svelte
index fdb11ecc..81ae73c2 100644
--- a/frontend/src/routes/studies/[[id]]/+page.svelte
+++ b/frontend/src/routes/studies/[[id]]/+page.svelte
@@ -133,7 +133,14 @@
 			{@const test = study.tests[current_step - 2]}
 			{#key test}
 				{#if test instanceof TestTask}
-					<LanguageTest languageTest={test} {user} {code} {rid} onFinish={() => current_step++} />
+					<LanguageTest
+						languageTest={test}
+						{user}
+						{code}
+						{rid}
+						study_id={study.id}
+						onFinish={() => current_step++}
+					/>
 				{:else if test instanceof TestTyping}
 					<div class="w-[1024px]">
 						<Typingbox
-- 
GitLab


From b991987bf09aabd9875752d07b7ce8e5c74c3e01 Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Wed, 5 Mar 2025 15:56:36 +0100
Subject: [PATCH 40/44] study_id column in test entry

---
 backend/app/models/tests.py  | 1 +
 backend/app/schemas/tests.py | 1 +
 2 files changed, 2 insertions(+)

diff --git a/backend/app/models/tests.py b/backend/app/models/tests.py
index bca7641e..35409101 100644
--- a/backend/app/models/tests.py
+++ b/backend/app/models/tests.py
@@ -247,6 +247,7 @@ class TestEntry(Base):
     rid = Column(String, nullable=True)
     user_id = Column(Integer, ForeignKey("users.id"), default=None, nullable=True)
     test_id = Column(Integer, ForeignKey("tests.id"), nullable=False)
+    study_id = Column(Integer, ForeignKey("studies.id"), nullable=False)
     created_at = Column(DateTime, default=datetime_aware)
 
     entry_task = relationship(
diff --git a/backend/app/schemas/tests.py b/backend/app/schemas/tests.py
index dea6c6d7..fb49e00e 100644
--- a/backend/app/schemas/tests.py
+++ b/backend/app/schemas/tests.py
@@ -154,6 +154,7 @@ class TestEntryCreate(BaseModel):
     rid: str | None = None
     user_id: int | None = None
     test_id: int
+    study_id: int
 
     entry_task: TestTaskEntryCreate | None = None
     entry_typing: TestTypingEntryCreate | None = None
-- 
GitLab


From dc579ff4dbc87976d1865922d4c4e5f7a249dc44 Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Wed, 5 Mar 2025 16:08:13 +0100
Subject: [PATCH 41/44] correction csv format for the download of studies

---
 backend/app/crud/studies.py | 24 +++++++++++++-----------
 1 file changed, 13 insertions(+), 11 deletions(-)

diff --git a/backend/app/crud/studies.py b/backend/app/crud/studies.py
index e13b1ca3..70a42bd5 100644
--- a/backend/app/crud/studies.py
+++ b/backend/app/crud/studies.py
@@ -83,15 +83,16 @@ def download_study(db: Session, study_id: int):
     writer = csv.writer(output)
 
     header = [
-        "study",
-        "test",
-        "group",
+        "study_id",
+        "test_id",
+        "group_id",
+        "group_name",
         "item_id",
-        "user id",
-        "item type",
+        "user_id",
+        "item_type",
         "response",
         "correct",
-        "response time",
+        "response_time",
     ]
     writer.writerow(header)
 
@@ -104,7 +105,6 @@ def download_study(db: Session, study_id: int):
             continue
 
         test_id = entry.test_id
-        test = crud.get_test(db, test_id).title
         group_id = entry.entry_task.test_group_id
         group = crud.get_group(db, group_id).title
         item = entry.entry_task.test_question_id
@@ -114,12 +114,13 @@ def download_study(db: Session, study_id: int):
         if entry.entry_task.entry_task_qcm:
             selected_id = entry.entry_task.entry_task_qcm.selected_id
             correct_id = entry.entry_task.test_question.question_qcm.correct
-            correct_answer = selected_id == correct_id
+            correct_answer = int(selected_id == correct_id)
 
             item_type = "qcm"
             row = [
                 study_id,
-                test,
+                test_id,
+                group_id,
                 group,
                 item,
                 user_id,
@@ -135,12 +136,13 @@ def download_study(db: Session, study_id: int):
             correct = extract_text_between_angle_bracket(
                 entry.entry_task.test_question.question
             )
-            correct_answer = answer == correct
+            correct_answer = int(answer == correct)
 
             item_type = "gapfill"
             row = [
                 study_id,
-                test,
+                test_id,
+                group_id,
                 group,
                 item,
                 user_id,
-- 
GitLab


From 997ebc3606ad9ca39bfb544d69fde993aa31d43b Mon Sep 17 00:00:00 2001
From: delphvr <delphine.vanrossum@student.uclouvain.be>
Date: Wed, 5 Mar 2025 21:53:29 +0100
Subject: [PATCH 42/44] code column added in the study csv

---
 backend/app/crud/studies.py | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/backend/app/crud/studies.py b/backend/app/crud/studies.py
index 70a42bd5..561fa1de 100644
--- a/backend/app/crud/studies.py
+++ b/backend/app/crud/studies.py
@@ -89,6 +89,7 @@ def download_study(db: Session, study_id: int):
         "group_name",
         "item_id",
         "user_id",
+        "code",
         "item_type",
         "response",
         "correct",
@@ -109,6 +110,7 @@ def download_study(db: Session, study_id: int):
         group = crud.get_group(db, group_id).title
         item = entry.entry_task.test_question_id
         user_id = entry.user_id
+        code = entry.code
         response_time = entry.entry_task.response_time
 
         if entry.entry_task.entry_task_qcm:
@@ -124,6 +126,7 @@ def download_study(db: Session, study_id: int):
                 group,
                 item,
                 user_id,
+                code,
                 item_type,
                 selected_id,
                 correct_answer,
@@ -146,6 +149,7 @@ def download_study(db: Session, study_id: int):
                 group,
                 item,
                 user_id,
+                code,
                 item_type,
                 answer,
                 correct_answer,
-- 
GitLab


From 69c553ee8307f0105e89257bf754eba69c4fa6ae Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Fri, 7 Mar 2025 15:10:58 +0100
Subject: [PATCH 43/44] Fix rebase with dev

---
 .DS_Store                                     | Bin 6148 -> 0 bytes
 backend/app/models.py                         | 182 ------------------
 backend/app/models/__init__.py                |   7 +-
 .../src/routes/register/[[studyId]]/+page.ts  |   2 +-
 4 files changed, 7 insertions(+), 184 deletions(-)
 delete mode 100644 .DS_Store
 delete mode 100644 backend/app/models.py

diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index 39ee3f3a5dfc0b2222c1f86429a3f770339027c4..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 6148
zcmeHKF>4e-6#m9+yi1gjbQTud*a|0DyM|K)6@;`F=I)X+m~)ThtZWvMG?sQYLa<Ya
zDJ?{>uuM`}rxhFX0|I_;<|X&`_AZr($UK<&_U65r_w6^#n_B>G@mZ$<%mb*i2`01b
z#+dk}RqULOTqZhMW4+s0YUjOvB{B{~1)>7~q5^Vu6I?|XYZ%z`JGXk}r}g#nX0x@P
zw>Vb+`u4@$#?H5!Wq;>PfB*5YHq5<wz^SOAg=Mty1S@!oj?F%uy;`V7SPp~b!{;{w
zhmm!9`H;!vD9dO1QxW*Ik(0?Pwdi8g@?nAd;rWyzET6r!IMJlWcQWf(K9i#?A0QPz
z2|8S-J;KCY`)qxd!|v;M=kI;cebKo#`#66ouk-s-rve-P4F=VnncY3!V*W~u<?*t0
z@az6Z&tpv7aCgp%v%D^TIb=%ev4&n{XWY?s>$5y|_TS(BzNLETT&u@8zs-wq!_~0K
zUD#muJ_aMZf$clYJ`!O$Je)Z_E0w6B5|g7!e4O0Vf6RDii`6d#jD1ufDi9SoRY2-P
z$|jg876E<fVCM@YG2yT`KKmL)Ol5$WDHZ`aLUUSMolbT7iQ&R@`csX|6pMgiI$S8t
zaGKfa7s^S6bg2)A%L2weDi9SYD=-y)&ddFOi)VPk_kVd({1p|53j9|JxTLw%T;wb1
z-CB7$xocClM{HseR|ND^*y(lbALJ^&#O95@R33<#ViAxdwD?EB%MiP$z+YA1H_`I#
AWdHyG

diff --git a/backend/app/models.py b/backend/app/models.py
deleted file mode 100644
index 03a0f2e4..00000000
--- a/backend/app/models.py
+++ /dev/null
@@ -1,182 +0,0 @@
-from sqlalchemy import (
-    Column,
-    Float,
-    Integer,
-    String,
-    Boolean,
-    ForeignKey,
-    DateTime,
-    UniqueConstraint,
-)
-from sqlalchemy.orm import relationship
-from enum import Enum
-
-from database import Base
-import datetime
-from utils import datetime_aware
-from sqlalchemy.dialects.postgresql import JSON
-
-
-class UserType(Enum):
-    ADMIN = 0
-    TUTOR = 1
-    STUDENT = 2
-
-
-class Contact(Base):
-    __tablename__ = "contacts"
-
-    user_id = Column(Integer, ForeignKey("users.id"), primary_key=True, index=True)
-    contact_id = Column(Integer, ForeignKey("users.id"), primary_key=True, index=True)
-
-    UniqueConstraint("user_id", "contact_id", name="unique_contact")
-
-
-class User(Base):
-    __tablename__ = "users"
-
-    id = Column(Integer, primary_key=True, index=True)
-    email = Column(String, unique=True, index=True)
-    nickname = Column(String, index=True)
-    password = Column(String)
-    type = Column(Integer, default=UserType.STUDENT.value)
-    is_active = Column(Boolean, default=True)
-    bio = Column(String, default="")
-    ui_language = Column(String, default="fr")
-    home_language = Column(String, default="en")
-    target_language = Column(String, default="fr")
-    birthdate = Column(DateTime, default=None)
-    gender = Column(String, default=None)
-    calcom_link = Column(String, default="")
-    last_survey = Column(DateTime, default=None)
-    availabilities = Column(JSON, default=[])
-    tutor_list = Column(JSON, default=[])
-    my_tutor = Column(String, default="")
-    my_slots = Column(JSON, default=[])
-
-    sessions = relationship(
-        "Session", secondary="user_sessions", back_populates="users"
-    )
-
-    contacts = relationship(
-        "User",
-        secondary="contacts",
-        primaryjoin=(id == Contact.user_id),
-        secondaryjoin=(id == Contact.contact_id),
-        back_populates="contacts",
-    )
-
-    contact_of = relationship(
-        "User",
-        secondary="contacts",
-        primaryjoin=(id == Contact.contact_id),
-        secondaryjoin=(id == Contact.user_id),
-        back_populates="contacts",
-    )
-
-    studies = relationship("Study", secondary="study_users", back_populates="users")
-
-
-class UserSurveyWeekly(Base):
-    __tablename__ = "users_survey_weekly"
-
-    id = Column(Integer, primary_key=True, index=True)
-    created_at = Column(DateTime, default=datetime_aware)
-    user_id = Column(Integer, ForeignKey("users.id"))
-    q1 = Column(Float)
-    q2 = Column(Float)
-    q3 = Column(Float)
-    q4 = Column(Float)
-
-
-class Session(Base):
-    __tablename__ = "sessions"
-
-    id = Column(Integer, primary_key=True, index=True)
-    created_at = Column(DateTime, default=datetime_aware)
-    is_active = Column(Boolean, default=True)
-    start_time = Column(DateTime, default=datetime_aware)
-    end_time = Column(
-        DateTime,
-        default=lambda: datetime_aware() + datetime.timedelta(hours=12),
-    )
-    language = Column(String, default="fr")
-
-    users = relationship("User", secondary="user_sessions", back_populates="sessions")
-
-
-class SessionSatisfy(Base):
-    __tablename__ = "session_satisfy"
-
-    id = Column(Integer, primary_key=True, index=True)
-    user_id = Column(Integer, ForeignKey("users.id"))
-    session_id = Column(Integer, ForeignKey("sessions.id"))
-    created_at = Column(DateTime, default=datetime_aware)
-    usefullness = Column(Integer)
-    easiness = Column(Integer)
-    remarks = Column(String)
-
-
-class UserSession(Base):
-    __tablename__ = "user_sessions"
-
-    user_id = Column(Integer, ForeignKey("users.id"), primary_key=True, index=True)
-    session_id = Column(String, ForeignKey("sessions.id"), primary_key=True, index=True)
-
-
-class Message(Base):
-    __tablename__ = "messages"
-
-    id = Column(Integer, primary_key=True, index=True)
-    message_id = Column(String)
-    content = Column(String)
-    user_id = Column(Integer, ForeignKey("users.id"))
-    session_id = Column(Integer, ForeignKey("sessions.id"))
-    created_at = Column(DateTime, default=datetime_aware)
-    reply_to_message_id = Column(
-        Integer, ForeignKey("messages.message_id"), nullable=True
-    )
-
-    feedbacks = relationship("MessageFeedback", backref="message")
-    replies = relationship(
-        "Message", backref="parent_message", remote_side=[message_id]
-    )
-
-    def raw(self):
-        return [
-            self.id,
-            self.message_id,
-            self.content,
-            self.user_id,
-            self.session_id,
-            self.reply_to_message_id,
-            self.created_at,
-        ]
-
-    feedbacks = relationship("MessageFeedback", backref="message")
-
-
-class MessageMetadata(Base):
-    __tablename__ = "message_metadata"
-
-    id = Column(Integer, primary_key=True, index=True)
-    message_id = Column(Integer, ForeignKey("messages.id"))
-    message = Column(String)
-    date = Column(Integer)
-
-    def raw(self):
-        return [self.id, self.message_id, self.message, self.date]
-
-
-class MessageFeedback(Base):
-    __tablename__ = "message_feedbacks"
-
-    id = Column(Integer, primary_key=True, index=True)
-    message_id = Column(Integer, ForeignKey("messages.id"))
-    start = Column(Integer)
-    end = Column(Integer)
-    content = Column(String, default="")
-    date = Column(DateTime, default=datetime_aware)
-
-    def raw(self):
-        return [self.id, self.message_id, self.start, self.end, self.content, self.date]
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 6185b5b5..131af455 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -1,4 +1,5 @@
 from sqlalchemy import (
+    JSON,
     Column,
     Float,
     Integer,
@@ -43,7 +44,7 @@ class User(Base):
     password = Column(String)
     type = Column(Integer, default=UserType.STUDENT.value)
     is_active = Column(Boolean, default=True)
-    availability = Column(String, default=0)
+    bio = Column(String, default="")
     ui_language = Column(String, default="fr")
     home_language = Column(String, default="en")
     target_language = Column(String, default="fr")
@@ -51,6 +52,10 @@ class User(Base):
     gender = Column(String, default=None)
     calcom_link = Column(String, default="")
     last_survey = Column(DateTime, default=None)
+    availabilities = Column(JSON, default=[])
+    tutor_list = Column(JSON, default=[])
+    my_tutor = Column(String, default="")
+    my_slots = Column(JSON, default=[])
 
     sessions = relationship(
         "Session", secondary="user_sessions", back_populates="users"
diff --git a/frontend/src/routes/register/[[studyId]]/+page.ts b/frontend/src/routes/register/[[studyId]]/+page.ts
index f0aa77df..9f2b5121 100644
--- a/frontend/src/routes/register/[[studyId]]/+page.ts
+++ b/frontend/src/routes/register/[[studyId]]/+page.ts
@@ -1,6 +1,6 @@
 import { getStudiesAPI, getStudyAPI } from '$lib/api/studies';
 import { getUsersAPI } from '$lib/api/users';
-import Study from '$lib/types/study.svelte';
+import Study from '$lib/types/study';
 import type { Load } from '@sveltejs/kit';
 
 export const load: Load = async ({ parent, fetch, params }) => {
-- 
GitLab


From 70bd266423f0220599bae291bb719b174a754cee Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Fri, 7 Mar 2025 16:27:38 +0100
Subject: [PATCH 44/44] Alembic script for new studies and tests logic

---
 .../344d94d32fa1_new_studies_tests_logic.py   | 129 ++++++++++++++++++
 1 file changed, 129 insertions(+)
 create mode 100644 backend/alembic/versions/344d94d32fa1_new_studies_tests_logic.py

diff --git a/backend/alembic/versions/344d94d32fa1_new_studies_tests_logic.py b/backend/alembic/versions/344d94d32fa1_new_studies_tests_logic.py
new file mode 100644
index 00000000..be65438a
--- /dev/null
+++ b/backend/alembic/versions/344d94d32fa1_new_studies_tests_logic.py
@@ -0,0 +1,129 @@
+"""New studies & tests logic
+
+Revision ID: 344d94d32fa1
+Revises: 9dfb49268c5f
+Create Date: 2025-03-07 15:43:38.917230
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "344d94d32fa1"
+down_revision: Union[str, None] = "9dfb49268c5f"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    with op.batch_alter_table("studies") as batch_op:
+        batch_op.drop_column("chat_duration")
+        batch_op.add_column(
+            sa.Column("nb_session", sa.Integer, nullable=False, server_default="8")
+        )
+        batch_op.add_column(
+            sa.Column(
+                "consent_participation",
+                sa.String,
+                nullable=False,
+                server_default="Si vous acceptez de participer, vous devrez seulement répondre à ce questionnaire et éventuellement à un autre test, si indiqué par la personne qui administre le test.",
+            )
+        )
+        batch_op.add_column(
+            sa.Column(
+                "consent_privacy",
+                sa.String,
+                nullable=False,
+                server_default="Les données collectées (vos réponses aux différentes questions) seront traitées de manière confidentielle et anonyme. Elles seront conservées après leur anonymisation intégrale et ne pourront être utilisées qu'à des fins scientifiques ou pédagogiques. Elles pourront éventuellement être partagées avec d'autres chercheurs ou enseignants, mais toujours dans ce cadre strictement de recherche ou d'enseignement.",
+            )
+        )
+        batch_op.add_column(
+            sa.Column(
+                "consent_rights",
+                sa.String,
+                nullable=False,
+                server_default="Votre participation à cette étude est volontaire. Vous pouvez à tout moment décider de ne plus participer à l'étude sans avoir à vous justifier. Vous pouvez également demander à ce que vos données soient supprimées à tout moment. Si vous avez des questions ou des préoccupations concernant cette étude, vous pouvez contacter le responsable de l'étude, Serge Bibauw, à l'adresse suivante :",
+            )
+        )
+        batch_op.add_column(
+            sa.Column(
+                "consent_study_data", sa.String, nullable=False, server_default=""
+            )
+        )
+
+    with op.batch_alter_table("studies") as batch_op:
+        batch_op.alter_column("nb_session", server_default=None)
+        batch_op.alter_column("consent_participation", server_default=None)
+        batch_op.alter_column("consent_privacy", server_default=None)
+        batch_op.alter_column("consent_rights", server_default=None)
+        batch_op.alter_column("consent_study_data", server_default=None)
+
+    op.drop_table("study_surveys")
+    op.drop_table("survey_group_questions")
+    op.drop_table("survey_groups")
+    op.drop_table("survey_questions")
+    op.drop_table("survey_response_info")  ## DATA LOSS
+    op.drop_table("survey_responses")  ## DATA LOSS
+    op.drop_table("survey_survey_groups")
+    op.drop_table("survey_surveys")
+    op.drop_table("test_typing")
+    op.drop_table("test_typing_entry")  ## DATA LOSS
+
+    # Auto-generated tables:
+    #   study_tests
+    #   test_entries
+    #   test_entries_task
+    #   test_entries_task_gapfill
+    #   test_entries_task_qcm
+    #   test_entries_typing
+    #   test_task_group_questions
+    #   test_task_groups
+    #   test_task_questions
+    #   test_task_questions_qcm
+    #   test_task_task_groups
+    #   test_tasks
+    #   tests
+
+
+def downgrade() -> None:
+    with op.batch_alter_table("studies") as batch_op:
+        batch_op.add_column(
+            sa.Column(
+                "chat_duration", sa.Integer, nullable=False, server_default="3600"
+            )
+        )
+        batch_op.drop_column("nb_session")
+        batch_op.drop_column("consent_participation")
+        batch_op.drop_column("consent_privacy")
+        batch_op.drop_column("consent_rights")
+        batch_op.drop_column("consent_study_data")
+
+    op.alter_column("studies", "chat_duration", server_default=None)
+
+    op.drop_table("study_tests")
+    op.drop_table("test_entries")  # DATA LOSS
+    op.drop_table("test_entries_task")  # DATA LOSS
+    op.drop_table("test_entries_task_gapfill")  # DATA LOSS
+    op.drop_table("test_entries_task_qcm")  # DATA LOSS
+    op.drop_table("test_entries_typing")  # DATA LOSS
+    op.drop_table("test_task_group_questions")
+    op.drop_table("test_task_groups")
+    op.drop_table("test_task_questions")
+    op.drop_table("test_task_questions_qcm")
+    op.drop_table("test_task_task_groups")
+    op.drop_table("test_tasks")
+    op.drop_table("tests")
+
+    ## Auto-generated tables:
+    #   survey_group_questions
+    #   survey_groups
+    #   survey_questions
+    #   survey_response_info
+    #   survey_responses
+    #   survey_survey_groups
+    #   survey_surveys
+    #   test_typing
+    #   test_typing_entry
-- 
GitLab