diff --git a/backend/app/crud/studies.py b/backend/app/crud/studies.py index 2e4b676fd55df27f29af9c34ffe2a96802fabe85..1c60d698a06ffcd7d76d2b456ea71ddae17252e0 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 4ae42314c45bb62055f30cbcdda0a15519f4b304..da3c9b832df4fd384f14085bcca4cf86f7ba961e 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 3d2b9e6c61a0297494d57236364c718c9e8c9af1..0de8a9911e7f59f0328186c60d5a9d5322ba670a 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 e2c1ba92a5cde009682fdbc566b9a45b76a1c360..63f6ad4fa95ed7b834230cda59762b814a4f995a 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 91889c13c0660bf995665a7e6bbc52f8ecbaffb6..a28ceb3ac8ee2313f86132d27e4e392e5ffe238e 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 faa6f9ab1ccb238b8aa52424e0dda57f83afaaf5..95fc21e0e99ec7800650decb359e9ce76a15658b 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 56eb3339fb1bba93492bf76097acfec925b718f5..316dc64f87ac0f894b74e155c9119aef7edf8ce7 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 ba781d4494c1d4533a8e0d30929fb8d4271e6b63..1dc4b1ca0886ce41b5b6ebf4ef609064b13410dd 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 ba7b272dfcd9882cf95f841816e58e562ee35925..ba78bf429e963347cae2844449307823c72c8d26 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 bca92b48e2d85a528b7ac2d2dde80eacce2ec276..8d1b3d9c29120fca192d8a1946c43d0084760f67 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 0000000000000000000000000000000000000000..dbd53f98b44270dbc351e2ab94aa77bee6c07cbd --- /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 +