From 5983e89eceab609301e865596979b7ec07a593e6 Mon Sep 17 00:00:00 2001 From: Brieuc Dubois <brieuc.a.dubois@student.uclouvain.be> Date: Fri, 7 Mar 2025 15:28:36 +0000 Subject: [PATCH] New studies & tests logic --- .DS_Store | Bin 6148 -> 0 bytes .../344d94d32fa1_new_studies_tests_logic.py | 129 +++++ backend/app/config.py | 1 - backend/app/{crud.py => crud/__init__.py} | 229 +------- backend/app/crud/studies.py | 166 ++++++ backend/app/crud/tests.py | 152 +++++ backend/app/main.py | 541 +----------------- backend/app/{models.py => models/__init__.py} | 136 +---- backend/app/models/studies.py | 41 ++ backend/app/models/tests.py | 276 +++++++++ .../app/routes/__init__.py | 0 backend/app/routes/decorators.py | 21 + backend/app/routes/studies.py | 73 +++ backend/app/routes/tests.py | 200 +++++++ .../app/{schemas.py => schemas/__init__.py} | 103 +--- backend/app/schemas/studies.py | 35 ++ backend/app/schemas/tests.py | 168 ++++++ backend/app/schemas/users.py | 83 +++ backend/app/test_main.py | 106 ---- backend/app/utils.py | 9 + docs/db-diagrams/studies-and-tests.pdf | Bin 0 -> 33101 bytes frontend/src/lang/fr.json | 38 +- frontend/src/lib/api/studies.ts | 16 +- frontend/src/lib/api/survey.ts | 14 +- frontend/src/lib/api/tests.ts | 117 +++- .../lib/components/studies/Draggable.svelte | 110 ++++ .../lib/components/studies/StudyForm.svelte | 322 +++++++++++ .../src/lib/components/surveys/gapfill.svelte | 2 +- .../lib/components/tests/languageTest.svelte | 210 +++++++ .../src/lib/components/tests/typingbox.svelte | 117 ++-- .../src/lib/components/utils/dateInput.svelte | 38 +- frontend/src/lib/types/study.ts | 189 +++++- frontend/src/lib/types/survey.ts | 4 + frontend/src/lib/types/surveyTyping.svelte.ts | 6 + frontend/src/lib/types/testTaskGroups.ts | 64 +++ frontend/src/lib/types/testTaskQuestions.ts | 147 +++++ frontend/src/lib/types/tests.ts | 149 +++++ frontend/src/lib/utils/arrays.ts | 12 + frontend/src/lib/utils/toasts.ts | 3 + frontend/src/routes/+layout.server.ts | 9 +- .../src/routes/admin/sessions/+page.svelte | 8 +- .../src/routes/admin/studies/+page.svelte | 299 +--------- frontend/src/routes/admin/studies/+page.ts | 2 +- .../routes/admin/studies/[id]/+page.server.ts | 78 +++ .../routes/admin/studies/[id]/+page.svelte | 19 + .../src/routes/admin/studies/[id]/+page.ts | 22 + .../routes/admin/studies/new/+page.server.ts | 96 ++++ .../src/routes/admin/studies/new/+page.svelte | 13 + .../src/routes/admin/studies/new/+page.ts | 11 + .../routes/register/[[studyId]]/+page.svelte | 2 +- .../src/routes/studies/[[id]]/+page.svelte | 222 +++++++ frontend/src/routes/studies/[[id]]/+page.ts | 25 + frontend/src/routes/tests/[id]/+page.svelte | 462 --------------- frontend/src/routes/tests/[id]/+page.ts | 24 - frontend/src/routes/tests/typing/+page.svelte | 15 - scripts/surveys/groups.csv | 1 - scripts/surveys/items.csv | 244 -------- scripts/surveys/questions_gapfill.csv | 62 ++ scripts/surveys/questions_qcm.csv | 175 ++++++ scripts/surveys/survey_maker.py | 206 +++++-- scripts/surveys/surveys.csv | 7 - scripts/surveys/tests_task.csv | 7 + scripts/surveys/tests_typing.csv | 5 + 63 files changed, 3765 insertions(+), 2276 deletions(-) delete mode 100644 .DS_Store create mode 100644 backend/alembic/versions/344d94d32fa1_new_studies_tests_logic.py 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 rename backend/app/{models.py => models/__init__.py} (56%) create mode 100644 backend/app/models/studies.py create mode 100644 backend/app/models/tests.py rename frontend/src/routes/tests/typing/+page.server.ts => backend/app/routes/__init__.py (100%) create mode 100644 backend/app/routes/decorators.py create mode 100644 backend/app/routes/studies.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 create mode 100644 docs/db-diagrams/studies-and-tests.pdf create mode 100644 frontend/src/lib/components/studies/Draggable.svelte create mode 100644 frontend/src/lib/components/studies/StudyForm.svelte create mode 100644 frontend/src/lib/components/tests/languageTest.svelte create mode 100644 frontend/src/lib/types/surveyTyping.svelte.ts 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 create mode 100644 frontend/src/lib/utils/arrays.ts create mode 100644 frontend/src/routes/admin/studies/[id]/+page.server.ts create mode 100644 frontend/src/routes/admin/studies/[id]/+page.svelte create mode 100644 frontend/src/routes/admin/studies/[id]/+page.ts 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 create mode 100644 frontend/src/routes/studies/[[id]]/+page.svelte create mode 100644 frontend/src/routes/studies/[[id]]/+page.ts 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.svelte delete mode 100644 scripts/surveys/items.csv create mode 100644 scripts/surveys/questions_gapfill.csv create mode 100644 scripts/surveys/questions_qcm.csv delete mode 100644 scripts/surveys/surveys.csv create mode 100644 scripts/surveys/tests_task.csv create mode 100644 scripts/surveys/tests_typing.csv 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/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 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..561fa1de --- /dev/null +++ b/backend/app/crud/studies.py @@ -0,0 +1,166 @@ +from typing import Optional +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: + 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 + + +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, exclude={"user_ids", "test_ids"})} + ) + + 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: + 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() + + +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_id", + "test_id", + "group_id", + "group_name", + "item_id", + "user_id", + "code", + "item_type", + "response", + "correct", + "response_time", + ] + writer.writerow(header) + + 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: + continue + + test_id = entry.test_id + 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 + code = entry.code + 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 = int(selected_id == correct_id) + + item_type = "qcm" + row = [ + study_id, + test_id, + group_id, + group, + item, + user_id, + code, + 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 = int(answer == correct) + + item_type = "gapfill" + row = [ + study_id, + test_id, + group_id, + group, + item, + user_id, + code, + 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/crud/tests.py b/backend/app/crud/tests.py new file mode 100644 index 00000000..d3297ff0 --- /dev/null +++ b/backend/app/crud/tests.py @@ -0,0 +1,152 @@ +from sqlalchemy.orm import Session + +from utils import extract_text_between_angle_bracket +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 | None: + 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.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() + + +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 +): + try: + testTask.groups.remove(group) + db.commit() + db.refresh(testTask) + except ValueError: + pass + + +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 | None: + 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 +): + try: + group.questions.remove(question) + db.commit() + db.refresh(group) + except ValueError: + pass + + +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.query(models.TestTaskQuestionQCM).filter( + models.TestTaskQuestionQCM.question_id == question_id + ).delete() + db.commit() + return None + + +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) + 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/main.py b/backend/app/main.py index d1a4cc36..ac9c7b29 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,8 @@ 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 +from routes.studies import studiesRouter websocket_users = defaultdict(lambda: defaultdict(set)) websocket_users_global = defaultdict(set) @@ -72,8 +71,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 +960,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 +1012,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 +1050,12 @@ 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) +v1Router.include_router(studiesRouter) apiRouter.include_router(v1Router) app.include_router(apiRouter) diff --git a/backend/app/models.py b/backend/app/models/__init__.py similarity index 56% rename from backend/app/models.py rename to backend/app/models/__init__.py index 2ae862f2..131af455 100644 --- a/backend/app/models.py +++ b/backend/app/models/__init__.py @@ -1,4 +1,5 @@ from sqlalchemy import ( + JSON, Column, Float, Integer, @@ -14,7 +15,9 @@ from enum import Enum from database import Base import datetime from utils import datetime_aware -from sqlalchemy.dialects.postgresql import JSON + +from models.studies import * +from models.tests import * class UserType(Enum): @@ -180,134 +183,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" - ) - - -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) diff --git a/backend/app/models/studies.py b/backend/app/models/studies.py new file mode 100644 index 00000000..fd0db73b --- /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) + nb_session = 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..35409101 --- /dev/null +++ b/backend/app/models/tests.py @@ -0,0 +1,276 @@ +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 + + +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) + + test = relationship( + "Test", uselist=False, back_populates="test_typing", lazy="selectin" + ) + + +class TestTask(Base): + __tablename__ = "test_tasks" + test_id = Column(Integer, ForeignKey("tests.id"), primary_key=True) + + test = relationship( + "Test", uselist=False, back_populates="test_task", lazy="selectin" + ) + groups = relationship( + "TestTaskGroup", + secondary="test_task_task_groups", + lazy="selectin", + ) + + +class Test(Base): + __tablename__ = "tests" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, nullable=False) + + 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: + 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: + if isinstance(value, dict): + return TestTask(**value, test_id=self.id) + else: + value.test_id = self.id + return value + + +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) + + title = Column(String, nullable=False) + demo = Column(Boolean, default=False) + randomize = Column(Boolean, default=True) + + questions = relationship( + "TestTaskQuestion", + secondary="test_task_group_questions", + lazy="selectin", + ) + + +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", + lazy="selectin", + ) + + @validates("question_qcm") + def adjust_question_qcm(self, _, value) -> TestTaskQuestionQCM | None: + if value: + if isinstance(value, dict): + return TestTaskQuestionQCM(**value, question_id=self.id) + else: + value.question_id = self.id + return value + + +class TestEntryTaskQCM(Base): + __tablename__ = "test_entries_task_qcm" + + entry_id = Column( + Integer, ForeignKey("test_entries_task.entry_id"), primary_key=True + ) + selected_id = Column(Integer, nullable=False) + + entry_task = relationship( + "TestEntryTask", + uselist=False, + back_populates="entry_task_qcm", + lazy="selectin", + ) + + +class TestEntryTaskGapfill(Base): + __tablename__ = "test_entries_task_gapfill" + + entry_id = Column( + Integer, ForeignKey("test_entries_task.entry_id"), primary_key=True, index=True + ) + text = Column(String, nullable=False) + + entry_task = relationship( + "TestEntryTask", + uselist=False, + back_populates="entry_task_gapfill", + lazy="selectin", + ) + + +class TestEntryTask(Base): + __tablename__ = "test_entries_task" + + entry_id = Column( + Integer, ForeignKey("test_entries.id"), primary_key=True, 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_task_qcm = relationship( + "TestEntryTaskQCM", + uselist=False, + back_populates="entry_task", + lazy="selectin", + ) + entry_task_gapfill = relationship( + "TestEntryTaskGapfill", + uselist=False, + back_populates="entry_task", + lazy="selectin", + ) + + test_question = relationship("TestTaskQuestion", uselist=False, lazy="selectin") + + entry = relationship("TestEntry", uselist=False, back_populates="entry_task") + + @validates("entry_task_qcm") + def adjust_entry_qcm(self, _, value) -> TestEntryTaskQCM | None: + if value: + 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) + + 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) + 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( + "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: + if isinstance(value, dict): + return TestEntryTyping(**value, entry_id=self.id) + else: + value.entry_id = self.id + return value diff --git a/frontend/src/routes/tests/typing/+page.server.ts b/backend/app/routes/__init__.py similarity index 100% rename from frontend/src/routes/tests/typing/+page.server.ts rename to backend/app/routes/__init__.py 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/studies.py b/backend/app/routes/studies.py new file mode 100644 index 00000000..0cba51e4 --- /dev/null +++ b/backend/app/routes/studies.py @@ -0,0 +1,73 @@ +from fastapi import APIRouter, Depends, HTTPException, 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), +): + 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.") +@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) + + +@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/backend/app/routes/tests.py b/backend/app/routes/tests.py new file mode 100644 index 00000000..f310d546 --- /dev/null +++ b/backend/app/routes/tests.py @@ -0,0 +1,200 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from starlette.status import HTTP_200_OK + +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), +): + 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.") +@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.TestEntryCreate, + 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.py b/backend/app/schemas/__init__.py similarity index 67% rename from backend/app/schemas.py rename to backend/app/schemas/__init__.py index ea40fd01..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 * 60 diff --git a/backend/app/schemas/studies.py b/backend/app/schemas/studies.py new file mode 100644 index 00000000..5f22ab24 --- /dev/null +++ b/backend/app/schemas/studies.py @@ -0,0 +1,35 @@ +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 + nb_session: int = 8 + consent_participation: str + consent_privacy: str + consent_rights: str + consent_study_data: str + + user_ids: list[int] = [] + test_ids: list[int] = [] + + +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/backend/app/schemas/tests.py b/backend/app/schemas/tests.py new file mode 100644 index 00000000..fb49e00e --- /dev/null +++ b/backend/app/schemas/tests.py @@ -0,0 +1,168 @@ +from typing_extensions import Self +from pydantic import BaseModel, model_validator + + +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(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): + id: int + question: str | None = None + 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 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 + + +class TestTaskEntryQCMCreate(BaseModel): + selected_id: int + + +class TestTaskEntryGapfillCreate(BaseModel): + text: str + + +class TestTaskEntryCreate(BaseModel): + test_group_id: int + test_question_id: int + response_time: float + + 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_task_qcm is None and self.entry_task_gapfill is None: + raise ValueError("QCM or Gapfill must be provided") + 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 + 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 + + @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/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/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/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 diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json index efeb4f3b..21fa5fef 100644 --- a/frontend/src/lang/fr.json +++ b/frontend/src/lang/fr.json @@ -345,7 +345,8 @@ "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)", + "nbSession": "Nombre de sessions", "updated": "Étude mise à jour avec succès", "noChanges": "Aucune modification", "updateError": "Erreur lors de la mise à jour de l'étude", @@ -364,7 +365,35 @@ "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", + "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", + "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é.", + "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.", + "tab": { + "study": "Étude", + "code": "Code", + "tests": "Tests", + "end": "Fin" + }, + "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", + "typingTests": "Tests de frappe" }, "button": { "create": "Créer", @@ -462,7 +491,10 @@ "users": "Utilisateurs", "description": "Description", "email": "E-mail", - "toggle": "Participants" + "toggle": "Participants", + "groups": "groupes", + "questions": "questions", + "tests": "tests" } }, "inputs": { diff --git a/frontend/src/lib/api/studies.ts b/frontend/src/lib/api/studies.ts index 7eff0b91..c4fa2439 100644 --- a/frontend/src/lib/api/studies.ts +++ b/frontend/src/lib/api/studies.ts @@ -26,7 +26,13 @@ export async function createStudyAPI( description: string, startDate: Date, endDate: Date, - chatDuration: number + nbSession: number, + test_ids: number[], + consentParticipation: string, + consentPrivacy: string, + consentRights: string, + consentStudyData: string, + user_ids: number[] ): Promise<number | null> { const response = await fetch('/api/studies', { method: 'POST', @@ -36,7 +42,13 @@ export async function createStudyAPI( description, start_date: formatToUTCDate(startDate), end_date: formatToUTCDate(endDate), - chat_duration: chatDuration + nb_session: nbSession, + test_ids, + consent_participation: consentParticipation, + consent_privacy: consentPrivacy, + consent_rights: consentRights, + consent_study_data: consentStudyData, + user_ids }) }); if (!response.ok) return null; diff --git a/frontend/src/lib/api/survey.ts b/frontend/src/lib/api/survey.ts index def03af3..cebdca29 100644 --- a/frontend/src/lib/api/survey.ts +++ b/frontend/src/lib/api/survey.ts @@ -1,7 +1,13 @@ import type { fetchType } from '$lib/utils/types'; +export async function getSurveysAPI(fetch: fetchType) { + 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(); @@ -19,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({ @@ -39,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(); @@ -55,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/api/tests.ts b/frontend/src/lib/api/tests.ts index f8c69cd7..54a3fe1f 100644 --- a/frontend/src/lib/api/tests.ts +++ b/frontend/src/lib/api/tests.ts @@ -1,11 +1,122 @@ 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 sendTestEntryTaskQcmAPI( + fetch: fetchType, + code: string, + rid: string | null, + user_id: number | null, + test_id: number, + study_id: number, + test_group_id: number, + test_question_id: number, + response_time: number, + selected_id: number +) { + const response = await fetch(`/api/tests/entries`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) + body: JSON.stringify({ + code, + rid, + user_id, + test_id, + study_id, + entry_task: { + test_group_id, + test_question_id, + response_time, + entry_task_qcm: { + selected_id + } + } + }) }); return response.ok; } + +export async function sendTestEntryTaskGapfillAPI( + fetch: fetchType, + code: string, + rid: string | null, + user_id: number | null, + test_id: number, + test_group_id: number, + study_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, + rid, + user_id, + test_id, + study_id, + entry_task: { + test_group_id, + test_question_id, + response_time, + entry_task_gapfill: { + text + } + } + }) + }); + + return response.ok; +} + +export async function sendTestEntryTypingAPI( + fetch: fetchType, + code: string, + rid: string | null, + user_id: number | null, + test_id: number, + study_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({ + code, + rid, + user_id, + test_id, + study_id, + entry_typing: { + position, + downtime, + uptime, + key_code, + key_value + } + }) + }); + + 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/studies/Draggable.svelte b/frontend/src/lib/components/studies/Draggable.svelte new file mode 100644 index 00000000..95fc21e0 --- /dev/null +++ b/frontend/src/lib/components/studies/Draggable.svelte @@ -0,0 +1,110 @@ +<script lang="ts"> + import { t } from '$lib/services/i18n'; + import { TestTask, TestTyping } from '$lib/types/tests'; + + let { items = $bindable(), name } = $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="w-full"> + {#each items as item} + <input type="hidden" {name} value={item.id} /> + {/each} + {#each items as item, index} + <li + class="p-3 bg-gray-200 border rounded-md select-none + 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' : ''}" + > + <div class="w-full"> + {#if item instanceof TestTask} + {item.title} ({item.groups.length} + {$t('utils.words.groups')}, {item.numQuestions} + {$t('utils.words.questions')}) + {:else if item instanceof TestTyping} + {item.title} + {/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> + <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" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + d="M6 18L18 6M6 6l12 12" + /> + </svg> + </button> + </div> + </li> + {/each} +</ul> diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte new file mode 100644 index 00000000..316dc64f --- /dev/null +++ b/frontend/src/lib/components/studies/StudyForm.svelte @@ -0,0 +1,322 @@ +<script lang="ts"> + import DateInput from '$lib/components/utils/dateInput.svelte'; + import Draggable from './Draggable.svelte'; + import autosize from 'svelte-autosize'; + 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'; + import User from '$lib/types/user'; + import type Study from '$lib/types/study'; + import { onMount } from 'svelte'; + import { TestTask, TestTyping, type Test } from '$lib/types/tests'; + import type SurveyTypingSvelte from '$lib/types/surveyTyping.svelte'; + + let { + study = $bindable(), + possibleTests, + mode, + data, + form + }: { + study: Study | null; + possibleTests: (Test | 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 nbSession = study ? study.nbSession : 8; + let tests = $state(study ? [...study.tests] : []); + 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); + let selectedTest: Test | 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. + */ + 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')); + 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; + } + + /** + * 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(); + 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> + <!-- if error message to display --> + {#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> + <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={description} + ></textarea> + + <!-- Dates --> + <label class="label" for="startDate">{$t('studies.startDate')} *</label> + <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={endDate} required /> + + <!-- number of Sessions --> + <label class="label" for="nbSession">{$t('studies.nbSession')} *</label> + <input + class="input w-full" + type="number" + id="nbSession" + name="nbSession" + min="0" + bind:value={nbSession} + required + /> + + <!-- Tests Section --> + <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}> + <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" + onclick={(e) => { + e.preventDefault(); + if (selectedTest === undefined) return; + tests = [...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>{$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>{$t('users.type.' + user.type)}</td> + <td> + {user.nickname} + <input type="hidden" name="users[]" value={user.id} /> + </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" + value={consentParticipation} + required + ></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" + value={consentPrivacy} + required + ></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" + value={consentRights} + required + ></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" + 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"> + {$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" + onclick={() => confirm($t('studies.deleteConfirm')) && deleteStudy()} + > + {$t('button.delete')} + </button> + {/if} + </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> + <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/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 new file mode 100644 index 00000000..cf0a2404 --- /dev/null +++ b/frontend/src/lib/components/tests/languageTest.svelte @@ -0,0 +1,210 @@ +<script lang="ts"> + import { sendTestEntryTaskGapfillAPI, sendTestEntryTaskQcmAPI } from '$lib/api/tests'; + 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'; + + let { + languageTest, + user, + code, + rid, + study_id, + onFinish = () => {} + }: { + languageTest: TestTask; + user: User | null; + code: string | null; + rid: string | null; + study_id: number; + onFinish: Function; + } = $props(); + + function getSortedQuestions(questions: TestTaskQuestion[]) { + return questions.sort(() => Math.random() - 0.5); + } + + let nAnswers = $state(1); + + 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 + ); + + let soundPlayer: HTMLAudioElement | null = $state(null); + + 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 { + console.log('END'); + 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 sendTestEntryTaskGapfillAPI( + fetch, + code || user?.email || '', + rid, + user?.id || null, + languageTest.id, + study_id, + currentGroup.id, + questions[currentQuestionId].id, + (new Date().getTime() - startTime) / 1000, + 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 sendTestEntryTaskQcmAPI( + fetch, + code || user?.email || '', + rid, + user?.id || null, + languageTest.id, + study_id, + currentGroup.id, + questions[currentQuestionId].id, + (new Date().getTime() - startTime) / 1000, + option + )) + ) { + 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 text-xl"> + <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/components/tests/typingbox.svelte b/frontend/src/lib/components/tests/typingbox.svelte index b6e8de11..9bdd821c 100644 --- a/frontend/src/lib/components/tests/typingbox.svelte +++ b/frontend/src/lib/components/tests/typingbox.svelte @@ -2,27 +2,33 @@ 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, + rid + }: { + typingTest: TestTyping; + onFinish: Function; + user: User | null; + code: string | null; + rid: 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 +38,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 +47,41 @@ } }, 1000); } + + async function sendTyping( + position: number, + downtime: number, + uptime: number, + key_code: number, + key_value: string + ) { + if ( + !(await sendTestEntryTypingAPI( + fetch, + code || user?.email || '', + rid, + 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 +96,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 +113,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 +123,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 +133,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/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..16f235ef 100644 --- a/frontend/src/lib/types/study.ts +++ b/frontend/src/lib/types/study.ts @@ -9,6 +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 { Test } from './tests'; export default class Study { private _id: number; @@ -16,8 +17,13 @@ export default class Study { private _description: string; private _startDate: Date; private _endDate: Date; - private _chatDuration: number; + private _nbSession: number; private _users: User[]; + private _consentParticipation: string; + private _consentPrivacy: string; + private _consentRights: string; + private _consentStudyData: string; + private _tests: Test[]; private constructor( id: number, @@ -25,16 +31,26 @@ export default class Study { description: string, startDate: Date, endDate: Date, - chatDuration: number, - users: User[] + nbSession: number, + users: User[], + consentParticipation: string, + consentPrivacy: string, + consentRights: string, + consentStudyData: string, + tests: Test[] ) { this._id = id; this._title = title; this._description = description; this._startDate = startDate; this._endDate = endDate; - this._chatDuration = chatDuration; + this._nbSession = nbSession; this._users = users; + this._consentParticipation = consentParticipation; + this._consentPrivacy = consentPrivacy; + this._consentRights = consentRights; + this._consentStudyData = consentStudyData; + this._tests = tests; } get id(): number { @@ -45,50 +61,162 @@ 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; } - get chatDuration(): number { - return this._chatDuration; + set endDate(value: Date) { + this._endDate = value; + } + + get nbSession(): number { + return this._nbSession; + } + + set nbSession(value: number) { + this._nbSession = 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(): Test[] { + return this._tests; + } + + set tests(value: Test[]) { + 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, startDate: Date, endDate: Date, - chatDuration: number, + nbSession: number, + consentParticipation: string, + consentPrivacy: string, + consentRights: string, + consentStudyData: string, + tests: Test[], + users: User[], 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, + nbSession, + tests.map((t) => t.id), + consentParticipation, + consentPrivacy, + consentRights, + consentStudyData, + users.map((u) => u.id) + ); if (id) { - return new Study(id, title, description, startDate, endDate, chatDuration, []); + return new Study( + id, + title, + description, + startDate, + endDate, + nbSession, + users, + consentParticipation, + consentPrivacy, + consentRights, + consentStudyData, + tests + ); } 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) { @@ -96,12 +224,25 @@ 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; + if (data.consent_study_data) this._consentStudyData = data.consent_study_data; + if (data.tests) this._tests = data.tests; return true; } 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) { @@ -111,6 +252,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) { @@ -120,6 +269,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'); @@ -132,15 +287,27 @@ export default class Study { json.description, parseToLocalDate(json.start_date), parseToLocalDate(json.end_date), - json.chat_duration, + json.nb_session, + [], + json.consent_participation, + json.consent_privacy, + json.consent_rights, + json.consent_study_data, [] ); study._users = User.parseAll(json.users); + study._tests = Test.parseAll(json.tests); 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'); 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/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/lib/types/testTaskGroups.ts b/frontend/src/lib/types/testTaskGroups.ts new file mode 100644 index 00000000..1b9eadfc --- /dev/null +++ b/frontend/src/lib/types/testTaskGroups.ts @@ -0,0 +1,64 @@ +import { toastAlert } from '$lib/utils/toasts'; +import { TestTaskQuestion } from './testTaskQuestions'; + +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, + randomize: boolean, + questions: TestTaskQuestion[] + ) { + this._id = id; + this._title = title; + this._demo = demo; + this._randomize = randomize; + this._questions = questions; + } + + get id(): number { + return this._id; + } + + get title(): string { + return this._title; + } + + get demo(): boolean { + return this._demo; + } + + get randomize(): boolean { + return this._randomize; + } + + 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, data.randomize, 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..afa027fa --- /dev/null +++ b/frontend/src/lib/types/testTaskQuestions.ts @@ -0,0 +1,147 @@ +import { shuffle } from '$lib/utils/arrays'; + +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 TestTaskQuestionGapfill.parse(data); + } + + 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 enum TestTaskQuestionQcmType { + image = 'image', + text = 'text', + audio = 'audio', + dropdown = 'dropdown', + radio = 'radio' +} + +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(): { 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; + } + + return new TestTaskQuestionQcm( + data.id, + data.question, + data.question_qcm.options, + data.question_qcm.correct + ); + } +} + +export class TestTaskQuestionGapfill extends TestTaskQuestion { + get answer(): string { + const match = super.question.match(/<([^>]+)>/); + return match ? match[1] : ''; + } + + 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)); + } + + 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; + } + return new TestTaskQuestionGapfill(data.id, data.question); + } +} diff --git a/frontend/src/lib/types/tests.ts b/frontend/src/lib/types/tests.ts new file mode 100644 index 00000000..e71ef0a1 --- /dev/null +++ b/frontend/src/lib/types/tests.ts @@ -0,0 +1,149 @@ +import { toastAlert } from '$lib/utils/toasts'; +import TestTaskGroup from './testTaskGroups'; + +export abstract class Test { + private _id: number; + private _title: string; + + 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'); + 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 _groups: TestTaskGroup[]; + + constructor(id: number, title: string, groups: TestTaskGroup[]) { + super(id, title); + this._groups = groups; + } + + 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.title, groups); + } +} + +export class TestTyping extends Test { + private _text: string; + private _duration: number; + private _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 { + return this._text; + } + + get duration(): number { + return this._duration; + } + + get repeat(): number { + 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'); + return null; + } + return new TestTyping( + data.id, + data.title, + data.test_typing.text, + data.test_typing.duration, + data.test_typing.repeat, + data.test_typing.explainations + ); + } +} 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/lib/utils/toasts.ts b/frontend/src/lib/utils/toasts.ts index a28dee8e..bade2a28 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 (typeof 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 (typeof 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 (typeof 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/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> diff --git a/frontend/src/routes/admin/studies/+page.svelte b/frontend/src/routes/admin/studies/+page.svelte index 56920d9d..5ca59fd3 100644 --- a/frontend/src/routes/admin/studies/+page.svelte +++ b/frontend/src/routes/admin/studies/+page.svelte @@ -1,167 +1,14 @@ <script lang="ts"> import { t } from '$lib/services/i18n'; - import Study from '$lib/types/study'; - 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 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(); 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 | null = $state(null); - let typingTest: boolean = $state(true); - - let studyCreate: 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 ?? null; - } - - 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), - chatDuration - }); - - if (result) { - selectStudy(null); - toastSuccess($t('studies.updated')); - } else { - toastAlert($t('studies.updateError')); - } - } - - 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); - - if (study) { - toastSuccess($t('studies.created')); - studyCreate = false; - studies.push(study); - } else { - toastAlert($t('studies.createError')); - } - } - - 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> @@ -177,138 +24,28 @@ </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> <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} </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}> - <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" 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" - /> - <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={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" - /> - <label class="label" for="typingTest">{$t('studies.typingTest')} *</label> - <input type="checkbox" class="input" id="typingTest" bind:checked={typingTest} /> - </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> - -<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/+page.ts b/frontend/src/routes/admin/studies/+page.ts index f4043711..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'; +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.server.ts b/frontend/src/routes/admin/studies/[id]/+page.server.ts new file mode 100644 index 00000000..389eb799 --- /dev/null +++ b/frontend/src/routes/admin/studies/[id]/+page.server.ts @@ -0,0 +1,78 @@ +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; + + 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 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(); + + if ( + !title || + !startDateStr || + !endDateStr || + !nbSessionStr || + !consentParticipation || + !consentPrivacy || + !consentRights || + !consentStudyData + ) { + return { message: 'Invalid request' }; + } + + const startDate = new Date(startDateStr); + const endDate = new Date(endDateStr); + const nbSession = parseInt(nbSessionStr, 10); + + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime()) || isNaN(nbSession)) { + return { message: 'Invalid date or session amount' }; + } + + if (startDate.getTime() > endDate.getTime()) { + return { message: 'End time cannot be before start time' }; + } + + const test_ids = formData + .getAll('tests[]') + .map((test) => { + try { + return JSON.parse(test.toString()); + } catch (e) { + return null; + } + }) + .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, + test_ids, + consent_participation: consentParticipation, + consent_privacy: consentPrivacy, + consent_rights: consentRights, + consent_study_data: consentStudyData, + user_ids + }); + + if (!updated) return { message: 'Failed to update study' }; + + 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 new file mode 100644 index 00000000..4c588c7c --- /dev/null +++ b/frontend/src/routes/admin/studies/[id]/+page.svelte @@ -0,0 +1,19 @@ +<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 typing = $state(new SurveyTypingSvelte()); + let possibleTests = [typing, ...data.tests]; + + let mode = 'edit'; +</script> + +{#if study} + <StudyForm {study} {possibleTests} {mode} {data} {form} /> +{:else} + <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 new file mode 100644 index 00000000..ba7b272d --- /dev/null +++ b/frontend/src/routes/admin/studies/[id]/+page.ts @@ -0,0 +1,22 @@ +import { getSurveysAPI } from '$lib/api/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 id = Number(params.id); + + const study = Study.parse(await getStudyAPI(fetch, id)); + + if (!study) { + redirect(303, '/admin/studies'); + } + + const tests = Test.parseAll(await getSurveysAPI(fetch)); + + return { + tests, + study + }; +}; 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..be9f5194 --- /dev/null +++ b/frontend/src/routes/admin/studies/new/+page.server.ts @@ -0,0 +1,96 @@ +import { createStudyAPI } from '$lib/api/studies'; +import { redirect, 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 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(); + + if ( + !title || + !startDateStr || + !endDateStr || + !nbSessionStr || + !consentParticipation || + !consentPrivacy || + !consentRights || + !consentStudyData + ) { + return { + message: 'Invalid request 1' + }; + } + + 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 2' + }; + } + + const nbSession = parseInt(nbSessionStr, 10); + if (isNaN(nbSession)) { + return { + message: 'Invalid request 3' + }; + } + + 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); + + const user_ids = formData.getAll('users[]').map((user) => parseInt(user.toString(), 10)); + + const id = await createStudyAPI( + fetch, + title, + description, + startDate, + endDate, + nbSession, + tests, + consentParticipation, + consentPrivacy, + consentRights, + consentStudyData, + user_ids + ); + + 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 new file mode 100644 index 00000000..25a9047f --- /dev/null +++ b/frontend/src/routes/admin/studies/new/+page.svelte @@ -0,0 +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 = null; + let typing = $state(new SurveyTypingSvelte()); + let possibleTests = [typing, ...data.tests]; + let mode = 'create'; +</script> + +<StudyForm {study} {possibleTests} {mode} {data} {form} /> 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..741a66fc --- /dev/null +++ b/frontend/src/routes/admin/studies/new/+page.ts @@ -0,0 +1,11 @@ +import { getSurveysAPI } from '$lib/api/survey'; +import { Test } from '$lib/types/tests'; +import { type Load } from '@sveltejs/kit'; + +export const load: Load = async ({ fetch }) => { + const tests = Test.parseAll(await getSurveysAPI(fetch)); + + return { + tests + }; +}; diff --git a/frontend/src/routes/register/[[studyId]]/+page.svelte b/frontend/src/routes/register/[[studyId]]/+page.svelte index 85ae010c..171c6e78 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'; let { data, form }: { data: PageData; form: FormData } = $props(); let study: Study | undefined = $state(data.study); diff --git a/frontend/src/routes/studies/[[id]]/+page.svelte b/frontend/src/routes/studies/[[id]]/+page.svelte new file mode 100644 index 00000000..81ae73c2 --- /dev/null +++ b/frontend/src/routes/studies/[[id]]/+page.svelte @@ -0,0 +1,222 @@ +<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'; + 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(); + + 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} + <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 h-full"> + {#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> + {:else if current_step < study.tests.length + 2} + {@const test = study.tests[current_step - 2]} + {#key test} + {#if test instanceof TestTask} + <LanguageTest + languageTest={test} + {user} + {code} + {rid} + study_id={study.id} + onFinish={() => current_step++} + /> + {:else if test instanceof TestTyping} + <div class="w-[1024px]"> + <Typingbox + typingTest={test} + onFinish={() => { + setTimeout(() => { + current_step++; + }, 3000); + }} + {user} + {code} + {rid} + /> + </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"> + <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"> + <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> 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 + }; +}; diff --git a/frontend/src/routes/tests/[id]/+page.svelte b/frontend/src/routes/tests/[id]/+page.svelte deleted file mode 100644 index a2c19dcb..00000000 --- a/frontend/src/routes/tests/[id]/+page.svelte +++ /dev/null @@ -1,462 +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 sid = - Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); - 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.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} 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/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..8d1b3d9c 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,44 @@ 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 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 = [] @@ -71,10 +90,10 @@ 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 -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 +101,28 @@ 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_, "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 @@ -99,22 +139,47 @@ 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 + + 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_items}/{len(items)} items") + print( + f"Successfully created {n_questions_gapfill}/{len(questions_gapfill)} gapfill questions" + ) # CREATE GROUPS @@ -124,25 +189,27 @@ 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 - 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( - 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}/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}') @@ -151,39 +218,60 @@ 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 + + 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( - 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_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_surveys}/{len(surveys)} surveys") + print(f"Successfully created {n_typing_tests}/{len(tests_typing)} typing tests") diff --git a/scripts/surveys/surveys.csv b/scripts/surveys/surveys.csv deleted file mode 100644 index 981ba828..00000000 --- a/scripts/surveys/surveys.csv +++ /dev/null @@ -1,7 +0,0 @@ -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 -5,Vocabulary test T2,21,23 -6,Vocabulary test T3,21,24 \ No newline at end of file diff --git a/scripts/surveys/tests_task.csv b/scripts/surveys/tests_task.csv new file mode 100644 index 00000000..0e428318 --- /dev/null +++ b/scripts/surveys/tests_task.csv @@ -0,0 +1,7 @@ +id,title,groups +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 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