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] 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