diff --git a/src/apps/answers/api.py b/src/apps/answers/api.py index b41138ebcfc3..72bb5f25ccea 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.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/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/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 a7e7ee958b6c..f0902c1bd303 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..6a9b3627dc70 --- /dev/null +++ b/src/apps/users/services/prolific_user.py @@ -0,0 +1,73 @@ +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 ProlificParamsActivityAnswer +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: 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 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) + + prolific_respondent = await crud.get_prolific_respondent(prolific_respondent_id) + if not prolific_respondent: + prolific_respondent = UserSchema( + id=prolific_respondent_id, + 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(), + is_prolific_respondent=True, + ) + + return await crud.save(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) + 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=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.domain}" + + 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 4bf6a7ab8543..d5921d4b8703 100644 --- a/src/apps/users/tests/unit/test_user_service.py +++ b/src/apps/users/tests/unit/test_user_service.py @@ -6,11 +6,13 @@ 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 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 +93,37 @@ 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, ProlificAnswerParams(prolific_pid="prolific_respondent_id", session_id="prolific_session_id", study_id="prolific_study_id")) + 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.domain}" + 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, 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.create_prolific_respondent() + await srv2.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, 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.create_prolific_respondent() + await srv2.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..ad534746febb --- /dev/null +++ b/src/config/prolific_respondent.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class ProlificRespondent(BaseModel): + domain: 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_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 ###