From 4327d96bf5837a4ddc925f01e9564e08859dbe21 Mon Sep 17 00:00:00 2001 From: Hamza Date: Fri, 24 Jan 2025 20:48:16 +0100 Subject: [PATCH 1/4] wip --- src/apps/answers/api.py | 19 ++- src/apps/users/cruds/user.py | 6 + src/apps/users/db/schemas.py | 1 + src/apps/users/services/prolific_user.py | 64 ++++++++ .../users/tests/unit/test_user_service.py | 51 ++++++ src/config/__init__.py | 2 + src/config/prolific_respondent.py | 9 ++ ...8_51-add_is_prolific_respondant_in_user.py | 150 ++++++++++++++++++ 8 files changed, 299 insertions(+), 3 deletions(-) create mode 100644 src/apps/users/services/prolific_user.py create mode 100644 src/config/prolific_respondent.py create mode 100644 src/infrastructure/database/migrations/versions/2025_01_24_18_51-add_is_prolific_respondant_in_user.py diff --git a/src/apps/answers/api.py b/src/apps/answers/api.py index b41138ebcfc3..fdd60f729c00 100644 --- a/src/apps/answers/api.py +++ b/src/apps/answers/api.py @@ -59,6 +59,7 @@ from apps.subjects.services import SubjectsService from apps.users import UsersCRUD from apps.users.domain import User +from apps.users.services.prolific_user import ProlificUserService from apps.workspaces.domain.constants import Role from apps.workspaces.service.check_access import CheckAccessService from apps.workspaces.service.workspace import WorkspaceService @@ -96,10 +97,22 @@ async def create_anonymous_answer( answer_session=Depends(get_answer_session), ) -> None: async with atomic(session): - anonymous_respondent = await UsersCRUD(session).get_anonymous_respondent() - assert anonymous_respondent + respondent = None + if schema.prolific_params: + service = ProlificUserService(session, schema.prolific_params) + respondent = await service.get_or_create_prolific_respondent() + + await ( + service.create_subject_for_prolific_respondent( + respondent, + schema.applet_id + ) + ) + else: + respondent = await UsersCRUD(session).get_anonymous_respondent() + assert respondent - service = AnswerService(session, anonymous_respondent.id, answer_session) + service = AnswerService(session, respondent.id, answer_session) if tz_offset is not None and schema.answer.tz_offset is None: schema.answer.tz_offset = tz_offset // 60 # value in minutes async with atomic(answer_session): diff --git a/src/apps/users/cruds/user.py b/src/apps/users/cruds/user.py index a7e7ee958b6c..dc96eac3341b 100644 --- a/src/apps/users/cruds/user.py +++ b/src/apps/users/cruds/user.py @@ -108,6 +108,12 @@ async def get_super_admin(self) -> UserSchema | None: db_result = await self._execute(query) return db_result.scalars().one_or_none() + async def get_prolific_respondent(self, id: uuid.UUID) -> UserSchema | None: + query: Query = select(UserSchema) + query = query.where(UserSchema.id == id and UserSchema.is_prolific_respondent == true()) + db_result = await self._execute(query) + return db_result.scalars().one_or_none() + async def get_anonymous_respondent(self) -> UserSchema | None: query: Query = select(UserSchema) query = query.where(UserSchema.is_anonymous_respondent == true()) diff --git a/src/apps/users/db/schemas.py b/src/apps/users/db/schemas.py index ffd4869988d3..5ba802b4adb0 100644 --- a/src/apps/users/db/schemas.py +++ b/src/apps/users/db/schemas.py @@ -19,6 +19,7 @@ class UserSchema(Base): is_super_admin = Column(Boolean(), default=False, server_default="false") is_anonymous_respondent = Column(Boolean(), default=False, server_default="false") is_legacy_deleted_respondent = Column(Boolean(), default=False, server_default="false") + is_prolific_respondent = Column(Boolean(), default=False, server_default="false") def __repr__(self) -> str: return f"UserSchema(id='{self.id}', email='{self.email}')" # pragma: no cover # noqa: E501 diff --git a/src/apps/users/services/prolific_user.py b/src/apps/users/services/prolific_user.py new file mode 100644 index 000000000000..b3487e5dea45 --- /dev/null +++ b/src/apps/users/services/prolific_user.py @@ -0,0 +1,64 @@ +import hashlib +import uuid +from apps.applets.crud.applets import AppletsCRUD +from apps.authentication.services.security import AuthenticationService +from apps.integrations.prolific.domain import ProlificAnswerParams +from apps.shared.hashing import hash_sha224 +from apps.subjects.domain import SubjectCreate +from apps.subjects.services.subjects import SubjectsService +from apps.users.cruds.user import UsersCRUD +from apps.users.db.schemas import UserSchema +from config import settings + +class ProlificUserService: + def __init__(self, session, prolific_participant: ProlificAnswerParams) -> None: + self.session = session + self.prolific_pid = prolific_participant.prolific_pid + self.prolific_session_id = prolific_participant.session_id + self.prolific_study_id = prolific_participant.study_id + + + async def get_or_create_prolific_respondent(self) -> UserSchema: + hash_object = hashlib.sha256(f"{self.prolific_pid}-{self.prolific_session_id}".encode('utf-8')) + prolific_respondent_id = uuid.UUID(hash_object.hexdigest()[:32]) + + crud = UsersCRUD(self.session) + + prolific_respondent = await crud.get_prolific_respondent(prolific_respondent_id) + if not prolific_respondent: + email = f"{self.prolific_pid}-{self.prolific_session_id}{settings.prolific_respondent.email}" + prolific_respondent = UserSchema( + id=prolific_respondent_id, + email=hash_sha224(email), + first_name=settings.prolific_respondent.first_name, + last_name=settings.prolific_respondent.last_name, + hashed_password=AuthenticationService(self.session).get_password_hash( + settings.anonymous_respondent.password + ), + email_encrypted=email, + is_prolific_respondent=True, + ) + + return await crud.save(prolific_respondent) + + return prolific_respondent + + async def create_subject_for_prolific_respondent(self, prolific_respondent: UserSchema, applet_id: uuid.UUID) -> None: + subject_service = SubjectsService(self.session, prolific_respondent.id) + subject = await subject_service.get_by_user_and_applet(prolific_respondent.id, applet_id) + applet = await AppletsCRUD(session=self.session).get_by_id(applet_id) + + if not subject or subject.is_deleted: + await subject_service.create( + SubjectCreate( + applet_id=applet_id, + creator_id=applet.creator_id, + user_id=prolific_respondent.id, + first_name=prolific_respondent.first_name, + last_name=prolific_respondent.last_name, + secret_user_id=settings.anonymous_respondent.secret_user_id, + email=settings.anonymous_respondent.email, + nickname=f"ProlificPID={self.prolific_pid}/SessionID={self.prolific_session_id}/StudyID={self.prolific_study_id}" + ) + ) + # else: Do nothing, subject already exists \ No newline at end of file diff --git a/src/apps/users/tests/unit/test_user_service.py b/src/apps/users/tests/unit/test_user_service.py index 4bf6a7ab8543..0f7cff5a651c 100644 --- a/src/apps/users/tests/unit/test_user_service.py +++ b/src/apps/users/tests/unit/test_user_service.py @@ -11,6 +11,7 @@ from apps.users.db.schemas import UserSchema from apps.users.domain import User, UserCreate from apps.users.errors import UserNotFound +from apps.users.services.prolific_user import ProlificUserService from apps.users.services.user import UserService from apps.workspaces.crud.workspaces import UserWorkspaceCRUD from config import settings @@ -91,6 +92,56 @@ async def test_create_anonymous_respondent__created_only_once( count = await crud.count(is_anonymous_respondent=True) assert count == 1 +async def test_create_prolific_respondent(session: AsyncSession): + crud = UsersCRUD(session) + await crud._delete(is_prolific_respondent=true()) + + srv = ProlificUserService(session, { + "prolific_pid": "prolific_respondent_id", + "session_id": "prolific_session_id", + "study_id": "prolific_study_id", + }) + prolific_respondent = await srv.get_or_create_prolific_respondent() + assert prolific_respondent.is_prolific_respondent + assert prolific_respondent.email_encrypted == f"{srv.prolific_pid}-{srv.prolific_session_id}{settings.prolific_respondent.email}" + assert prolific_respondent.first_name == settings.prolific_respondent.first_name + assert prolific_respondent.last_name == settings.prolific_respondent.last_name + +async def test_create_prolific_respondent__created_only_once(session: AsyncSession): + srv1 = ProlificUserService(session, { + "prolific_pid": "prolific_respondent_id", + "session_id": "prolific_session_id", + "study_id": "prolific_study_id", + }) + srv2 = ProlificUserService(session, { + "prolific_pid": "prolific_respondent_id", + "session_id": "prolific_session_id", + "study_id": "prolific_study_id", + }) + await srv1.get_or_create_prolific_respondent() + await srv2.get_or_create_prolific_respondent() + crud = UsersCRUD(session) + count = await crud.count(is_prolific_respondent=True) + assert count == 1 + +async def test_create_two_different_sessions_prolific_respondent(session: AsyncSession): + srv1 = ProlificUserService(session, { + "prolific_pid": "prolific_respondent_id", + "session_id": "prolific_session_id-1", + "study_id": "prolific_study_id", + }) + srv2 = ProlificUserService(session, { + "prolific_pid": "prolific_respondent_id", + "session_id": "prolific_session_id-2", + "study_id": "prolific_study_id", + }) + + await srv1.get_or_create_prolific_respondent() + await srv2.get_or_create_prolific_respondent() + crud = UsersCRUD(session) + count = await crud.count(is_prolific_respondent=True) + assert count == 2 + async def test_create_user(session: AsyncSession): crud = UsersCRUD(session) diff --git a/src/config/__init__.py b/src/config/__init__.py index 38ddffbebc8f..0eb06d041d61 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -14,6 +14,7 @@ from config.multiinformant import MultiInformantSettings from config.notification import FirebaseCloudMessagingSettings from config.opentelemetry import OpenTelemetrySettings +from config.prolific_respondent import ProlificRespondent from config.rabbitmq import RabbitMQSettings from config.redis import RedisSettings from config.secret import SecretSettings @@ -81,6 +82,7 @@ class Settings(BaseSettings): super_admin = SuperAdmin() anonymous_respondent = AnonymousRespondent() + prolific_respondent = ProlificRespondent() task_answer_encryption = AnswerEncryption() task_audio_file_convert = AudioFileConvert() diff --git a/src/config/prolific_respondent.py b/src/config/prolific_respondent.py new file mode 100644 index 000000000000..b20fdd105384 --- /dev/null +++ b/src/config/prolific_respondent.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class ProlificRespondent(BaseModel): + email: str = "@prolific.com" + password: str = "prolificRespondentPassword!" + first_name: str = "Prolific" + last_name: str = "ChildMindInstitute" + secret_user_id: str = "Prolific PID: " diff --git a/src/infrastructure/database/migrations/versions/2025_01_24_18_51-add_is_prolific_respondant_in_user.py b/src/infrastructure/database/migrations/versions/2025_01_24_18_51-add_is_prolific_respondant_in_user.py new file mode 100644 index 000000000000..7d59973ce214 --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2025_01_24_18_51-add_is_prolific_respondant_in_user.py @@ -0,0 +1,150 @@ +"""Add is Prolific Respondant in User + +Revision ID: eaac80a03cee +Revises: dc2dd9e195d5 +Create Date: 2025-01-24 18:51:22.163500 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "eaac80a03cee" +down_revision = "dc2dd9e195d5" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("uq_activity_assignments_activity_flow_respondent_target", table_name="activity_assignments") + op.drop_index("uq_activity_assignments_activity_respondent_target", table_name="activity_assignments") + op.drop_table("activity_assignments") + op.drop_table("ml_loris_user_relationship") + op.alter_column( + "answers", "applet_history_id", existing_type=sa.VARCHAR(), type_=sa.Text(), existing_nullable=False + ) + op.alter_column("answers", "flow_history_id", existing_type=sa.VARCHAR(), type_=sa.Text(), existing_nullable=True) + op.alter_column( + "answers", "activity_history_id", existing_type=sa.VARCHAR(), type_=sa.Text(), existing_nullable=False + ) + op.alter_column( + "subjects", "language", existing_type=sa.VARCHAR(length=20), type_=sa.String(length=5), existing_nullable=True + ) + op.alter_column( + "token_blacklist", + "type", + existing_type=postgresql.ENUM("ACCESS", "REFRESH", name="token_purpose"), + type_=sa.Enum("ACCESS", "REFRESH", name="token_purpose"), + existing_nullable=False, + ) + op.add_column("users", sa.Column("is_prolific_respondent", sa.Boolean(), server_default="false", nullable=True)) + op.alter_column( + "users", "email", existing_type=sa.VARCHAR(length=100), type_=sa.String(length=56), existing_nullable=True + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "users", "email", existing_type=sa.String(length=56), type_=sa.VARCHAR(length=100), existing_nullable=True + ) + op.drop_column("users", "is_prolific_respondent") + op.alter_column( + "token_blacklist", + "type", + existing_type=sa.Enum("ACCESS", "REFRESH", name="token_purpose"), + type_=postgresql.ENUM("ACCESS", "REFRESH", name="token_purpose"), + existing_nullable=False, + ) + op.alter_column( + "subjects", "language", existing_type=sa.String(length=5), type_=sa.VARCHAR(length=20), existing_nullable=True + ) + op.alter_column( + "answers", "activity_history_id", existing_type=sa.Text(), type_=sa.VARCHAR(), existing_nullable=False + ) + op.alter_column("answers", "flow_history_id", existing_type=sa.Text(), type_=sa.VARCHAR(), existing_nullable=True) + op.alter_column( + "answers", "applet_history_id", existing_type=sa.Text(), type_=sa.VARCHAR(), existing_nullable=False + ) + op.create_table( + "ml_loris_user_relationship", + sa.Column( + "id", postgresql.UUID(), server_default=sa.text("gen_random_uuid()"), autoincrement=False, nullable=False + ), + sa.Column( + "created_at", + postgresql.TIMESTAMP(), + server_default=sa.text("timezone('utc'::text, now())"), + autoincrement=False, + nullable=True, + ), + sa.Column( + "updated_at", + postgresql.TIMESTAMP(), + server_default=sa.text("timezone('utc'::text, now())"), + autoincrement=False, + nullable=True, + ), + sa.Column("migrated_date", postgresql.TIMESTAMP(), autoincrement=False, nullable=True), + sa.Column("migrated_updated", postgresql.TIMESTAMP(), autoincrement=False, nullable=True), + sa.Column("is_deleted", sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column("ml_user_uuid", postgresql.UUID(), autoincrement=False, nullable=False), + sa.Column("loris_user_id", sa.VARCHAR(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint("ml_user_uuid", name="pk_ml_loris_user_relationship"), + sa.UniqueConstraint("loris_user_id", name="uq_ml_loris_user_relationship_loris_user_id"), + ) + op.create_table( + "activity_assignments", + sa.Column("is_deleted", sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column( + "created_at", + postgresql.TIMESTAMP(), + server_default=sa.text("timezone('utc'::text, now())"), + autoincrement=False, + nullable=True, + ), + sa.Column( + "updated_at", + postgresql.TIMESTAMP(), + server_default=sa.text("timezone('utc'::text, now())"), + autoincrement=False, + nullable=True, + ), + sa.Column("migrated_date", postgresql.TIMESTAMP(), autoincrement=False, nullable=True), + sa.Column("migrated_updated", postgresql.TIMESTAMP(), autoincrement=False, nullable=True), + sa.Column("id", postgresql.UUID(), autoincrement=False, nullable=False), + sa.Column("activity_flow_id", postgresql.UUID(), autoincrement=False, nullable=True), + sa.Column("activity_id", postgresql.UUID(), autoincrement=False, nullable=True), + sa.Column("respondent_subject_id", postgresql.UUID(), autoincrement=False, nullable=False), + sa.Column("target_subject_id", postgresql.UUID(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint( + ["respondent_subject_id"], + ["subjects.id"], + name="fk_activity_assignments_respondent_subject_id_subjects", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["target_subject_id"], + ["subjects.id"], + name="fk_activity_assignments_target_subject_id_subjects", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name="pk_activity_assignments"), + ) + op.create_index( + "uq_activity_assignments_activity_respondent_target", + "activity_assignments", + ["activity_id", "respondent_subject_id", "target_subject_id"], + unique=True, + ) + op.create_index( + "uq_activity_assignments_activity_flow_respondent_target", + "activity_assignments", + ["activity_flow_id", "respondent_subject_id", "target_subject_id"], + unique=True, + ) + # ### end Alembic commands ### From 7dabc5fe6e9476eda89862810ea92b7228673eaa Mon Sep 17 00:00:00 2001 From: Hamza Date: Mon, 27 Jan 2025 18:55:27 +0100 Subject: [PATCH 2/4] wip --- ...0_55-add_is_prolific_respondant_in_user.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/infrastructure/database/migrations/versions/2025_01_24_20_55-add_is_prolific_respondant_in_user.py diff --git a/src/infrastructure/database/migrations/versions/2025_01_24_20_55-add_is_prolific_respondant_in_user.py b/src/infrastructure/database/migrations/versions/2025_01_24_20_55-add_is_prolific_respondant_in_user.py new file mode 100644 index 000000000000..0c518e1be53d --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2025_01_24_20_55-add_is_prolific_respondant_in_user.py @@ -0,0 +1,29 @@ +"""Add is Prolific Respondant in User + +Revision ID: 1e448d205193 +Revises: dc2dd9e195d5 +Create Date: 2025-01-24 20:55:53.626692 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "1e448d205193" +down_revision = "dc2dd9e195d5" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("users", sa.Column("is_prolific_respondent", sa.Boolean(), server_default="false", nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("users", "is_prolific_respondent") + # ### end Alembic commands ### From 2ccffa770e73803fd264fac8da9da202a982d939 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 28 Jan 2025 17:25:53 +0100 Subject: [PATCH 3/4] wip --- src/apps/answers/service.py | 12 +- src/apps/users/services/prolific_user.py | 24 ++- .../users/tests/unit/test_user_service.py | 32 +--- ...8_51-add_is_prolific_respondant_in_user.py | 150 ------------------ 4 files changed, 24 insertions(+), 194 deletions(-) delete mode 100644 src/infrastructure/database/migrations/versions/2025_01_24_18_51-add_is_prolific_respondant_in_user.py diff --git a/src/apps/answers/service.py b/src/apps/answers/service.py index a65f12a7141c..669077128d72 100644 --- a/src/apps/answers/service.py +++ b/src/apps/answers/service.py @@ -135,7 +135,7 @@ def key_generator(pk: uuid.UUID) -> str: return key_generator async def create_answer(self, activity_answer: AppletAnswerCreate) -> AnswerSchema: - if self.user_id: + if self.user_id and activity_answer.prolific_params == None: return await self._create_respondent_answer(activity_answer) else: return await self._create_anonymous_answer(activity_answer) @@ -316,16 +316,6 @@ async def _create_answer(self, applet_answer: AppletAnswerCreate) -> AnswerSchem ) if not respondent_subject or not respondent_subject.soft_exists(): raise ValidationError("Respondent subject not found") - - if applet_answer.prolific_params: - await subject_crud.update_by_id( - respondent_subject.id, - **dict({ - "secret_user_id":f"ProlificPID:{applet_answer.prolific_params.prolific_pid}\ - /SessionID:{applet_answer.prolific_params.session_id}\ - /StudyID:{applet_answer.prolific_params.study_id}" - }) - ) if applet_answer.input_subject_id: input_subject = await subject_crud.get_by_id(applet_answer.input_subject_id) diff --git a/src/apps/users/services/prolific_user.py b/src/apps/users/services/prolific_user.py index b3487e5dea45..5cc33241c90f 100644 --- a/src/apps/users/services/prolific_user.py +++ b/src/apps/users/services/prolific_user.py @@ -1,5 +1,7 @@ import hashlib import uuid + +from fastapi import HTTPException from apps.applets.crud.applets import AppletsCRUD from apps.authentication.services.security import AuthenticationService from apps.integrations.prolific.domain import ProlificAnswerParams @@ -26,22 +28,23 @@ async def get_or_create_prolific_respondent(self) -> UserSchema: prolific_respondent = await crud.get_prolific_respondent(prolific_respondent_id) if not prolific_respondent: - email = f"{self.prolific_pid}-{self.prolific_session_id}{settings.prolific_respondent.email}" prolific_respondent = UserSchema( id=prolific_respondent_id, - email=hash_sha224(email), + email=hash_sha224(self.__get_formated_email()), first_name=settings.prolific_respondent.first_name, last_name=settings.prolific_respondent.last_name, hashed_password=AuthenticationService(self.session).get_password_hash( settings.anonymous_respondent.password ), - email_encrypted=email, + email_encrypted=self.__get_formated_email(), is_prolific_respondent=True, ) return await crud.save(prolific_respondent) - - return prolific_respondent + + # As the id is generated from the prolific_pid and prolific_session_id + # that means the user already answered the survey with the session_id + raise HTTPException(status_code=400, detail="User already answered the survey") async def create_subject_for_prolific_respondent(self, prolific_respondent: UserSchema, applet_id: uuid.UUID) -> None: subject_service = SubjectsService(self.session, prolific_respondent.id) @@ -56,9 +59,14 @@ async def create_subject_for_prolific_respondent(self, prolific_respondent: User user_id=prolific_respondent.id, first_name=prolific_respondent.first_name, last_name=prolific_respondent.last_name, - secret_user_id=settings.anonymous_respondent.secret_user_id, - email=settings.anonymous_respondent.email, + secret_user_id=self.__get_formated_secret_user_id(), + email=self.__get_formated_email(), nickname=f"ProlificPID={self.prolific_pid}/SessionID={self.prolific_session_id}/StudyID={self.prolific_study_id}" ) ) - # else: Do nothing, subject already exists \ No newline at end of file + + def __get_formated_email(self): + return f"{self.prolific_pid}-{self.prolific_session_id}{settings.prolific_respondent.email}" + + def __get_formated_secret_user_id(self): + return f"{settings.prolific_respondent.secret_user_id}{self.prolific_pid}-{self.prolific_study_id}-{self.prolific_session_id}" \ No newline at end of file diff --git a/src/apps/users/tests/unit/test_user_service.py b/src/apps/users/tests/unit/test_user_service.py index 0f7cff5a651c..b16137df577d 100644 --- a/src/apps/users/tests/unit/test_user_service.py +++ b/src/apps/users/tests/unit/test_user_service.py @@ -6,6 +6,7 @@ from sqlalchemy import true from sqlalchemy.ext.asyncio import AsyncSession +from apps.integrations.prolific.domain import ProlificAnswerParams from apps.shared.hashing import hash_sha224 from apps.users.cruds.user import UsersCRUD from apps.users.db.schemas import UserSchema @@ -96,11 +97,7 @@ async def test_create_prolific_respondent(session: AsyncSession): crud = UsersCRUD(session) await crud._delete(is_prolific_respondent=true()) - srv = ProlificUserService(session, { - "prolific_pid": "prolific_respondent_id", - "session_id": "prolific_session_id", - "study_id": "prolific_study_id", - }) + srv = ProlificUserService(session, ProlificAnswerParams(prolific_pid="prolific_respondent_id", session_id="prolific_session_id", study_id="prolific_study_id")) prolific_respondent = await srv.get_or_create_prolific_respondent() assert prolific_respondent.is_prolific_respondent assert prolific_respondent.email_encrypted == f"{srv.prolific_pid}-{srv.prolific_session_id}{settings.prolific_respondent.email}" @@ -108,16 +105,9 @@ async def test_create_prolific_respondent(session: AsyncSession): assert prolific_respondent.last_name == settings.prolific_respondent.last_name async def test_create_prolific_respondent__created_only_once(session: AsyncSession): - srv1 = ProlificUserService(session, { - "prolific_pid": "prolific_respondent_id", - "session_id": "prolific_session_id", - "study_id": "prolific_study_id", - }) - srv2 = ProlificUserService(session, { - "prolific_pid": "prolific_respondent_id", - "session_id": "prolific_session_id", - "study_id": "prolific_study_id", - }) + srv1 = ProlificUserService(session, ProlificAnswerParams(prolific_pid="prolific_respondent_id", session_id="prolific_session_id", study_id="prolific_study_id")) + srv2 = ProlificUserService(session, ProlificAnswerParams(prolific_pid="prolific_respondent_id", session_id="prolific_session_id", study_id="prolific_study_id")) + await srv1.get_or_create_prolific_respondent() await srv2.get_or_create_prolific_respondent() crud = UsersCRUD(session) @@ -125,16 +115,8 @@ async def test_create_prolific_respondent__created_only_once(session: AsyncSessi assert count == 1 async def test_create_two_different_sessions_prolific_respondent(session: AsyncSession): - srv1 = ProlificUserService(session, { - "prolific_pid": "prolific_respondent_id", - "session_id": "prolific_session_id-1", - "study_id": "prolific_study_id", - }) - srv2 = ProlificUserService(session, { - "prolific_pid": "prolific_respondent_id", - "session_id": "prolific_session_id-2", - "study_id": "prolific_study_id", - }) + srv1 = ProlificUserService(session, ProlificAnswerParams(prolific_pid="prolific_respondent_id", session_id="prolific_session_id-1", study_id="prolific_study_id")) + srv2 = ProlificUserService(session, ProlificAnswerParams(prolific_pid="prolific_respondent_id", session_id="prolific_session_id-2", study_id="prolific_study_id")) await srv1.get_or_create_prolific_respondent() await srv2.get_or_create_prolific_respondent() diff --git a/src/infrastructure/database/migrations/versions/2025_01_24_18_51-add_is_prolific_respondant_in_user.py b/src/infrastructure/database/migrations/versions/2025_01_24_18_51-add_is_prolific_respondant_in_user.py deleted file mode 100644 index 7d59973ce214..000000000000 --- a/src/infrastructure/database/migrations/versions/2025_01_24_18_51-add_is_prolific_respondant_in_user.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Add is Prolific Respondant in User - -Revision ID: eaac80a03cee -Revises: dc2dd9e195d5 -Create Date: 2025-01-24 18:51:22.163500 - -""" - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "eaac80a03cee" -down_revision = "dc2dd9e195d5" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index("uq_activity_assignments_activity_flow_respondent_target", table_name="activity_assignments") - op.drop_index("uq_activity_assignments_activity_respondent_target", table_name="activity_assignments") - op.drop_table("activity_assignments") - op.drop_table("ml_loris_user_relationship") - op.alter_column( - "answers", "applet_history_id", existing_type=sa.VARCHAR(), type_=sa.Text(), existing_nullable=False - ) - op.alter_column("answers", "flow_history_id", existing_type=sa.VARCHAR(), type_=sa.Text(), existing_nullable=True) - op.alter_column( - "answers", "activity_history_id", existing_type=sa.VARCHAR(), type_=sa.Text(), existing_nullable=False - ) - op.alter_column( - "subjects", "language", existing_type=sa.VARCHAR(length=20), type_=sa.String(length=5), existing_nullable=True - ) - op.alter_column( - "token_blacklist", - "type", - existing_type=postgresql.ENUM("ACCESS", "REFRESH", name="token_purpose"), - type_=sa.Enum("ACCESS", "REFRESH", name="token_purpose"), - existing_nullable=False, - ) - op.add_column("users", sa.Column("is_prolific_respondent", sa.Boolean(), server_default="false", nullable=True)) - op.alter_column( - "users", "email", existing_type=sa.VARCHAR(length=100), type_=sa.String(length=56), existing_nullable=True - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.alter_column( - "users", "email", existing_type=sa.String(length=56), type_=sa.VARCHAR(length=100), existing_nullable=True - ) - op.drop_column("users", "is_prolific_respondent") - op.alter_column( - "token_blacklist", - "type", - existing_type=sa.Enum("ACCESS", "REFRESH", name="token_purpose"), - type_=postgresql.ENUM("ACCESS", "REFRESH", name="token_purpose"), - existing_nullable=False, - ) - op.alter_column( - "subjects", "language", existing_type=sa.String(length=5), type_=sa.VARCHAR(length=20), existing_nullable=True - ) - op.alter_column( - "answers", "activity_history_id", existing_type=sa.Text(), type_=sa.VARCHAR(), existing_nullable=False - ) - op.alter_column("answers", "flow_history_id", existing_type=sa.Text(), type_=sa.VARCHAR(), existing_nullable=True) - op.alter_column( - "answers", "applet_history_id", existing_type=sa.Text(), type_=sa.VARCHAR(), existing_nullable=False - ) - op.create_table( - "ml_loris_user_relationship", - sa.Column( - "id", postgresql.UUID(), server_default=sa.text("gen_random_uuid()"), autoincrement=False, nullable=False - ), - sa.Column( - "created_at", - postgresql.TIMESTAMP(), - server_default=sa.text("timezone('utc'::text, now())"), - autoincrement=False, - nullable=True, - ), - sa.Column( - "updated_at", - postgresql.TIMESTAMP(), - server_default=sa.text("timezone('utc'::text, now())"), - autoincrement=False, - nullable=True, - ), - sa.Column("migrated_date", postgresql.TIMESTAMP(), autoincrement=False, nullable=True), - sa.Column("migrated_updated", postgresql.TIMESTAMP(), autoincrement=False, nullable=True), - sa.Column("is_deleted", sa.BOOLEAN(), autoincrement=False, nullable=True), - sa.Column("ml_user_uuid", postgresql.UUID(), autoincrement=False, nullable=False), - sa.Column("loris_user_id", sa.VARCHAR(), autoincrement=False, nullable=False), - sa.PrimaryKeyConstraint("ml_user_uuid", name="pk_ml_loris_user_relationship"), - sa.UniqueConstraint("loris_user_id", name="uq_ml_loris_user_relationship_loris_user_id"), - ) - op.create_table( - "activity_assignments", - sa.Column("is_deleted", sa.BOOLEAN(), autoincrement=False, nullable=True), - sa.Column( - "created_at", - postgresql.TIMESTAMP(), - server_default=sa.text("timezone('utc'::text, now())"), - autoincrement=False, - nullable=True, - ), - sa.Column( - "updated_at", - postgresql.TIMESTAMP(), - server_default=sa.text("timezone('utc'::text, now())"), - autoincrement=False, - nullable=True, - ), - sa.Column("migrated_date", postgresql.TIMESTAMP(), autoincrement=False, nullable=True), - sa.Column("migrated_updated", postgresql.TIMESTAMP(), autoincrement=False, nullable=True), - sa.Column("id", postgresql.UUID(), autoincrement=False, nullable=False), - sa.Column("activity_flow_id", postgresql.UUID(), autoincrement=False, nullable=True), - sa.Column("activity_id", postgresql.UUID(), autoincrement=False, nullable=True), - sa.Column("respondent_subject_id", postgresql.UUID(), autoincrement=False, nullable=False), - sa.Column("target_subject_id", postgresql.UUID(), autoincrement=False, nullable=False), - sa.ForeignKeyConstraint( - ["respondent_subject_id"], - ["subjects.id"], - name="fk_activity_assignments_respondent_subject_id_subjects", - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["target_subject_id"], - ["subjects.id"], - name="fk_activity_assignments_target_subject_id_subjects", - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id", name="pk_activity_assignments"), - ) - op.create_index( - "uq_activity_assignments_activity_respondent_target", - "activity_assignments", - ["activity_id", "respondent_subject_id", "target_subject_id"], - unique=True, - ) - op.create_index( - "uq_activity_assignments_activity_flow_respondent_target", - "activity_assignments", - ["activity_flow_id", "respondent_subject_id", "target_subject_id"], - unique=True, - ) - # ### end Alembic commands ### From 4044f40872cad83306291012d759ef7f395ab974 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 30 Jan 2025 15:26:37 +0100 Subject: [PATCH 4/4] wip --- src/apps/answers/api.py | 2 +- src/apps/integrations/prolific/errors.py | 10 ++++++++ .../integrations/prolific/service/prolific.py | 19 ++++++++------ src/apps/users/cruds/user.py | 4 +-- src/apps/users/services/prolific_user.py | 25 ++++++++++--------- .../users/tests/unit/test_user_service.py | 12 ++++----- src/config/prolific_respondent.py | 2 +- 7 files changed, 44 insertions(+), 30 deletions(-) diff --git a/src/apps/answers/api.py b/src/apps/answers/api.py index fdd60f729c00..72bb5f25ccea 100644 --- a/src/apps/answers/api.py +++ b/src/apps/answers/api.py @@ -100,7 +100,7 @@ async def create_anonymous_answer( respondent = None if schema.prolific_params: service = ProlificUserService(session, schema.prolific_params) - respondent = await service.get_or_create_prolific_respondent() + respondent = await service.create_prolific_respondent() await ( service.create_subject_for_prolific_respondent( diff --git a/src/apps/integrations/prolific/errors.py b/src/apps/integrations/prolific/errors.py index aa5145aead48..9bd78ab55262 100644 --- a/src/apps/integrations/prolific/errors.py +++ b/src/apps/integrations/prolific/errors.py @@ -1,7 +1,17 @@ from gettext import gettext as _ +from pydantic import ValidationError + from apps.shared.exception import UnauthorizedError class ProlificInvalidApiTokenError(UnauthorizedError): message = _("Prolific token is invalid.") + + +class ProlificInvalidStudyError(UnauthorizedError): + message = _("Invalid Prolific study id.") + + +class ProlificIntegrationNotConfiguredError(ValidationError): + message = _("Prolific integration not configured.") \ No newline at end of file diff --git a/src/apps/integrations/prolific/service/prolific.py b/src/apps/integrations/prolific/service/prolific.py index 44bc0b5b9510..62a20a67a663 100644 --- a/src/apps/integrations/prolific/service/prolific.py +++ b/src/apps/integrations/prolific/service/prolific.py @@ -10,10 +10,10 @@ from apps.integrations.crud.integrations import IntegrationsCRUD from apps.integrations.db.schemas import IntegrationsSchema from apps.integrations.domain import AvailableIntegrations -from apps.integrations.prolific.errors import ProlificInvalidApiTokenError +from apps.integrations.prolific.errors import ProlificIntegrationNotConfiguredError, ProlificInvalidApiTokenError, ProlificInvalidStudyError from apps.integrations.prolific.domain import ProlificCompletionCode, ProlificCompletionCodeList, ProlificIntegration, PublicProlificIntegration -PROLIFIC_API_BASE_URL = "https://api.prolific.com/api/v1" +API_BASE_URL = "https://api.prolific.com/api/v1" BASE_HEADERS = {"Content-Type": "application/json"} class ProlificIntegrationService: @@ -24,7 +24,7 @@ def __init__(self, applet_id: uuid.UUID, session) -> None: async def create_prolific_integration(self, api_key: str) -> ProlificIntegration: prolific_response = requests.get( - f"{PROLIFIC_API_BASE_URL}/users/me/", + f"{API_BASE_URL}/users/me/", headers={ **BASE_HEADERS, "Authorization": f"Token {api_key}" @@ -52,27 +52,30 @@ async def get_public_prolific_integration(self, study_id, language) -> PublicPro api_key = await self._get_prolific_api_key() + if not api_key: + return PublicProlificIntegration(enabled=False) + prolific_response = requests.get( - f"{PROLIFIC_API_BASE_URL}/studies/{study_id}/", + f"{API_BASE_URL}/studies/{study_id}/", headers={ **BASE_HEADERS, "Authorization": f"Token {api_key}" }) - return PublicProlificIntegration(enabled=(api_key is not None and prolific_response.status_code == 200)) + return PublicProlificIntegration(enabled=(prolific_response.status_code == 200)) async def get_completion_codes(self, study_id: str) -> ProlificCompletionCodeList: api_key = await self._get_prolific_api_key() prolific_response = requests.get( - f"{PROLIFIC_API_BASE_URL}/studies/{study_id}/", + f"{API_BASE_URL}/studies/{study_id}/", headers={ **BASE_HEADERS, "Authorization": f"Token {api_key}" }) if (prolific_response.status_code != 200): - raise HTTPException(status_code=prolific_response.status_code, detail=prolific_response.detail) + raise ProlificInvalidStudyError(detail=prolific_response.detail) return ProlificCompletionCodeList(completion_codes=prolific_response.json()["completion_codes"]) @@ -88,4 +91,4 @@ async def _get_prolific_api_key(self) -> str | None: try: return ProlificIntegration(**json.loads(integration.configuration)).api_key except ValidationError: - raise HTTPException(status_code=400, detail="Prolific integration is not configured") + raise ProlificIntegrationNotConfiguredError() diff --git a/src/apps/users/cruds/user.py b/src/apps/users/cruds/user.py index dc96eac3341b..f0902c1bd303 100644 --- a/src/apps/users/cruds/user.py +++ b/src/apps/users/cruds/user.py @@ -108,9 +108,9 @@ async def get_super_admin(self) -> UserSchema | None: db_result = await self._execute(query) return db_result.scalars().one_or_none() - async def get_prolific_respondent(self, id: uuid.UUID) -> UserSchema | None: + async def get_prolific_respondent(self, id_: uuid.UUID) -> UserSchema | None: query: Query = select(UserSchema) - query = query.where(UserSchema.id == id and UserSchema.is_prolific_respondent == true()) + query = query.where(UserSchema.id == id_ and UserSchema.is_prolific_respondent == true()) db_result = await self._execute(query) return db_result.scalars().one_or_none() diff --git a/src/apps/users/services/prolific_user.py b/src/apps/users/services/prolific_user.py index 5cc33241c90f..6a9b3627dc70 100644 --- a/src/apps/users/services/prolific_user.py +++ b/src/apps/users/services/prolific_user.py @@ -4,7 +4,7 @@ from fastapi import HTTPException from apps.applets.crud.applets import AppletsCRUD from apps.authentication.services.security import AuthenticationService -from apps.integrations.prolific.domain import ProlificAnswerParams +from apps.integrations.prolific.domain import ProlificParamsActivityAnswer from apps.shared.hashing import hash_sha224 from apps.subjects.domain import SubjectCreate from apps.subjects.services.subjects import SubjectsService @@ -13,15 +13,15 @@ from config import settings class ProlificUserService: - def __init__(self, session, prolific_participant: ProlificAnswerParams) -> None: + def __init__(self, session, prolific_participant: ProlificParamsActivityAnswer) -> None: self.session = session self.prolific_pid = prolific_participant.prolific_pid self.prolific_session_id = prolific_participant.session_id self.prolific_study_id = prolific_participant.study_id - async def get_or_create_prolific_respondent(self) -> UserSchema: - hash_object = hashlib.sha256(f"{self.prolific_pid}-{self.prolific_session_id}".encode('utf-8')) + async def create_prolific_respondent(self) -> UserSchema: + hash_object = hashlib.sha256(f"{self.prolific_pid}-{self.prolific_study_id}".encode('utf-8')) prolific_respondent_id = uuid.UUID(hash_object.hexdigest()[:32]) crud = UsersCRUD(self.session) @@ -30,13 +30,13 @@ async def get_or_create_prolific_respondent(self) -> UserSchema: if not prolific_respondent: prolific_respondent = UserSchema( id=prolific_respondent_id, - email=hash_sha224(self.__get_formated_email()), + email=hash_sha224(self._get_formated_email()), first_name=settings.prolific_respondent.first_name, last_name=settings.prolific_respondent.last_name, hashed_password=AuthenticationService(self.session).get_password_hash( settings.anonymous_respondent.password ), - email_encrypted=self.__get_formated_email(), + email_encrypted=self._get_formated_email(), is_prolific_respondent=True, ) @@ -59,14 +59,15 @@ async def create_subject_for_prolific_respondent(self, prolific_respondent: User user_id=prolific_respondent.id, first_name=prolific_respondent.first_name, last_name=prolific_respondent.last_name, - secret_user_id=self.__get_formated_secret_user_id(), - email=self.__get_formated_email(), - nickname=f"ProlificPID={self.prolific_pid}/SessionID={self.prolific_session_id}/StudyID={self.prolific_study_id}" + secret_user_id=self._get_formated_secret_user_id(), + email=self._get_formated_email(), + # Storing prolific params as JSON for easy parse. + nickname=ProlificParamsActivityAnswer(prolific_pid=self.prolific_pid, session_id=self.prolific_session_id, study_id=self.prolific_study_id).json(), ) ) - def __get_formated_email(self): - return f"{self.prolific_pid}-{self.prolific_session_id}{settings.prolific_respondent.email}" + def _get_formated_email(self): + return f"{self.prolific_pid}-{self.prolific_session_id}@{settings.prolific_respondent.domain}" - def __get_formated_secret_user_id(self): + def _get_formated_secret_user_id(self): return f"{settings.prolific_respondent.secret_user_id}{self.prolific_pid}-{self.prolific_study_id}-{self.prolific_session_id}" \ No newline at end of file diff --git a/src/apps/users/tests/unit/test_user_service.py b/src/apps/users/tests/unit/test_user_service.py index b16137df577d..d5921d4b8703 100644 --- a/src/apps/users/tests/unit/test_user_service.py +++ b/src/apps/users/tests/unit/test_user_service.py @@ -98,9 +98,9 @@ async def test_create_prolific_respondent(session: AsyncSession): await crud._delete(is_prolific_respondent=true()) srv = ProlificUserService(session, ProlificAnswerParams(prolific_pid="prolific_respondent_id", session_id="prolific_session_id", study_id="prolific_study_id")) - prolific_respondent = await srv.get_or_create_prolific_respondent() + prolific_respondent = await srv.create_prolific_respondent() assert prolific_respondent.is_prolific_respondent - assert prolific_respondent.email_encrypted == f"{srv.prolific_pid}-{srv.prolific_session_id}{settings.prolific_respondent.email}" + assert prolific_respondent.email_encrypted == f"{srv.prolific_pid}-{srv.prolific_session_id}{settings.prolific_respondent.domain}" assert prolific_respondent.first_name == settings.prolific_respondent.first_name assert prolific_respondent.last_name == settings.prolific_respondent.last_name @@ -108,8 +108,8 @@ async def test_create_prolific_respondent__created_only_once(session: AsyncSessi srv1 = ProlificUserService(session, ProlificAnswerParams(prolific_pid="prolific_respondent_id", session_id="prolific_session_id", study_id="prolific_study_id")) srv2 = ProlificUserService(session, ProlificAnswerParams(prolific_pid="prolific_respondent_id", session_id="prolific_session_id", study_id="prolific_study_id")) - await srv1.get_or_create_prolific_respondent() - await srv2.get_or_create_prolific_respondent() + await srv1.create_prolific_respondent() + await srv2.create_prolific_respondent() crud = UsersCRUD(session) count = await crud.count(is_prolific_respondent=True) assert count == 1 @@ -118,8 +118,8 @@ async def test_create_two_different_sessions_prolific_respondent(session: AsyncS srv1 = ProlificUserService(session, ProlificAnswerParams(prolific_pid="prolific_respondent_id", session_id="prolific_session_id-1", study_id="prolific_study_id")) srv2 = ProlificUserService(session, ProlificAnswerParams(prolific_pid="prolific_respondent_id", session_id="prolific_session_id-2", study_id="prolific_study_id")) - await srv1.get_or_create_prolific_respondent() - await srv2.get_or_create_prolific_respondent() + await srv1.create_prolific_respondent() + await srv2.create_prolific_respondent() crud = UsersCRUD(session) count = await crud.count(is_prolific_respondent=True) assert count == 2 diff --git a/src/config/prolific_respondent.py b/src/config/prolific_respondent.py index b20fdd105384..ad534746febb 100644 --- a/src/config/prolific_respondent.py +++ b/src/config/prolific_respondent.py @@ -2,7 +2,7 @@ class ProlificRespondent(BaseModel): - email: str = "@prolific.com" + domain: str = "prolific.com" password: str = "prolificRespondentPassword!" first_name: str = "Prolific" last_name: str = "ChildMindInstitute"