diff --git a/src/apps/answers/domain/answers.py b/src/apps/answers/domain/answers.py index e17820ec2c69..5d151613276a 100644 --- a/src/apps/answers/domain/answers.py +++ b/src/apps/answers/domain/answers.py @@ -17,6 +17,7 @@ from apps.activity_flows.domain.flow_full import FlowFull, FlowHistoryWithActivityFlat, FlowHistoryWithActivityFull from apps.answers.domain.answer_items import AnswerItem, ItemAnswerCreate from apps.applets.domain.base import AppletBaseInfo +from apps.integrations.prolific.domain import ProlificParamsActivityAnswer from apps.shared.domain import InternalModel, PublicModel, Response from apps.shared.domain.custom_validations import datetime_from_ms from apps.shared.domain.types import _BaseModel @@ -113,6 +114,7 @@ class AppletAnswerCreate(InternalModel): source_subject_id: uuid.UUID | None = None input_subject_id: uuid.UUID | None = None consent_to_share: bool | None = False + prolific_params: ProlificParamsActivityAnswer | None = None _dates_from_ms = validator("created_at", pre=True, allow_reuse=True)(datetime_from_ms) diff --git a/src/apps/answers/service.py b/src/apps/answers/service.py index c468a624b11c..a65f12a7141c 100644 --- a/src/apps/answers/service.py +++ b/src/apps/answers/service.py @@ -316,6 +316,16 @@ 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/api.py b/src/apps/integrations/prolific/api.py new file mode 100644 index 000000000000..f652413b5d7a --- /dev/null +++ b/src/apps/integrations/prolific/api.py @@ -0,0 +1,22 @@ +import uuid + +from fastapi import Depends +from apps.integrations.prolific.domain import PublicProlificIntegration +from apps.integrations.prolific.service.prolific import ProlificIntegrationService +from infrastructure.database.deps import get_session +from infrastructure.http.deps import get_language + + +async def get_public_prolific_integration( + applet_id: uuid.UUID, + study_id: str, + language: str = Depends(get_language), + session=Depends(get_session), +) -> PublicProlificIntegration: + return await ProlificIntegrationService(session=session, applet_id=applet_id).get_public_prolific_integration(study_id, language) + +async def get_study_completion_codes( + study_id: str, + applet_id: uuid.UUID, + session=Depends(get_session)) -> list[str]: + return await ProlificIntegrationService(session=session, applet_id=applet_id).get_completion_codes(study_id) diff --git a/src/apps/integrations/prolific/domain.py b/src/apps/integrations/prolific/domain.py index bf6c38fedbc2..98989d12a245 100644 --- a/src/apps/integrations/prolific/domain.py +++ b/src/apps/integrations/prolific/domain.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from apps.integrations.db.schemas import IntegrationsSchema +from apps.shared.domain.base import InternalModel class ProlificIntegration(BaseModel): @@ -15,3 +16,23 @@ def from_schema(cls, schema: IntegrationsSchema): def __repr__(self): return "ProlificIntegration()" + +class PublicProlificIntegration(InternalModel): + enabled: bool + +class ProlificParamsActivityAnswer(InternalModel): + prolific_pid: str + session_id: str + study_id: str + +class ProlificAction(InternalModel): + action: str + +class ProlificCompletionCode(InternalModel): + code: str + code_type: str + actions: list[ProlificAction] + actor: str + +class ProlificCompletionCodeList(InternalModel): + completion_codes: list[ProlificCompletionCode] diff --git a/src/apps/integrations/prolific/router.py b/src/apps/integrations/prolific/router.py new file mode 100644 index 000000000000..71aa9a83f955 --- /dev/null +++ b/src/apps/integrations/prolific/router.py @@ -0,0 +1,34 @@ +from fastapi.routing import APIRouter + +from starlette import status + +from apps.integrations.prolific.api import get_public_prolific_integration, get_study_completion_codes +from apps.integrations.prolific.domain import ProlificCompletionCodeList, PublicProlificIntegration +from apps.shared.domain.response.errors import AUTHENTICATION_ERROR_RESPONSES, DEFAULT_OPENAPI_RESPONSE + + +router = APIRouter(prefix="/integrations/prolific", tags=["Prolific"]) + +router.get( + "/applet/{applet_id}/study_id/{study_id}", + description="This endpoint is used to get the prolific configuration for an applet", + response_model=None, + status_code=status.HTTP_200_OK, + responses={ + status.HTTP_200_OK: {"model": PublicProlificIntegration}, + **DEFAULT_OPENAPI_RESPONSE, + **AUTHENTICATION_ERROR_RESPONSES, + } +)(get_public_prolific_integration) + +router.get( + "/applet/{applet_id}/completion_codes/{study_id}", + description="This endpoint is used to get the list of completion codes for a given study", + response_model=None, + status_code=status.HTTP_200_OK, + responses={ + status.HTTP_200_OK: {"model": ProlificCompletionCodeList}, + **DEFAULT_OPENAPI_RESPONSE, + **AUTHENTICATION_ERROR_RESPONSES, + }, +)(get_study_completion_codes) diff --git a/src/apps/integrations/prolific/service/prolific.py b/src/apps/integrations/prolific/service/prolific.py index 882a922b96f9..44bc0b5b9510 100644 --- a/src/apps/integrations/prolific/service/prolific.py +++ b/src/apps/integrations/prolific/service/prolific.py @@ -1,26 +1,34 @@ +import ast +from http.client import HTTPException +import json import uuid +from pydantic import ValidationError import requests +from apps.applets.service.applet import AppletService from apps.integrations.crud.integrations import IntegrationsCRUD from apps.integrations.db.schemas import IntegrationsSchema from apps.integrations.domain import AvailableIntegrations -from apps.integrations.prolific.domain import ProlificIntegration from apps.integrations.prolific.errors import ProlificInvalidApiTokenError -from apps.users.domain import User +from apps.integrations.prolific.domain import ProlificCompletionCode, ProlificCompletionCodeList, ProlificIntegration, PublicProlificIntegration +PROLIFIC_API_BASE_URL = "https://api.prolific.com/api/v1" +BASE_HEADERS = {"Content-Type": "application/json"} class ProlificIntegrationService: - def __init__(self, applet_id: uuid.UUID, session, user: User) -> None: + def __init__(self, applet_id: uuid.UUID, session) -> None: self.applet_id = applet_id self.session = session - self.user = user self.type = AvailableIntegrations.PROLIFIC async def create_prolific_integration(self, api_key: str) -> ProlificIntegration: prolific_response = requests.get( - "https://api.prolific.com/api/v1/users/me/", - headers={"Authorization": f"Token {api_key}", "Content-Type": "application/json"}, + f"{PROLIFIC_API_BASE_URL}/users/me/", + headers={ + **BASE_HEADERS, + "Authorization": f"Token {api_key}" + }, ) if prolific_response.status_code != 200: @@ -30,10 +38,54 @@ async def create_prolific_integration(self, api_key: str) -> ProlificIntegration IntegrationsSchema( applet_id=self.applet_id, type=self.type, - configuration={ - "api_key": api_key, - }, + configuration=ProlificIntegration(api_key=api_key).json(), ) ) return ProlificIntegration.from_schema(integration_schema) + + async def get_public_prolific_integration(self, study_id, language) -> PublicProlificIntegration: + applet_service = AppletService(self.session, uuid.UUID("00000000-0000-0000-0000-000000000000")) + await applet_service.exist_by_key(self.applet_id) + applet_base_info = await applet_service.get_info_by_key(self.applet_id, language) + self.applet_id = applet_base_info.id # Update the public applet key to be the real applet id + + api_key = await self._get_prolific_api_key() + + prolific_response = requests.get( + f"{PROLIFIC_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)) + + 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}/", + 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) + + return ProlificCompletionCodeList(completion_codes=prolific_response.json()["completion_codes"]) + + async def _get_prolific_api_key(self) -> str | None: + integration = await IntegrationsCRUD(self.session).retrieve_by_applet_and_type( + applet_id=self.applet_id, + integration_type=self.type + ) + + if not integration: + return None + + try: + return ProlificIntegration(**json.loads(integration.configuration)).api_key + except ValidationError: + raise HTTPException(status_code=400, detail="Prolific integration is not configured") diff --git a/src/apps/integrations/prolific/tests/test_prolific_service.py b/src/apps/integrations/prolific/tests/test_prolific_service.py new file mode 100644 index 000000000000..1276881ad0a1 --- /dev/null +++ b/src/apps/integrations/prolific/tests/test_prolific_service.py @@ -0,0 +1,57 @@ + +from unittest.mock import AsyncMock, patch +import uuid + +from apps.shared.test.base import BaseTest +from apps.shared.test.client import TestClient +from apps.users.domain import User + +from apps.integrations.prolific.router import router as prolific_router + +class TestProlificService(BaseTest): + + @patch('apps.integrations.prolific.service.prolific.requests.get') + async def test_get_completion_codes(self, mock_get, client: TestClient, user: User, uuid_zero: uuid.UUID): + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "completion_codes": [ + { + "code": "code1", + "code_type": "type1", + "actions": ["action1"], + "actor": "actor1" + }, + { + "code": "code2", + "code_type": "type2", + "actions": ["action2"], + "actor": "actor2" + } + ] + } + mock_get.return_value = mock_response + + study_id = "study123" + client.login(user) + + completion_codes_url = f"/integrations/prolific/applet/{uuid_zero}/completion_codes/{study_id}" + result = await client.get(completion_codes_url) + + assert len(result.completion_codes) == 2 + assert result.completion_codes[0].code == "code1" + assert result.completion_codes[1].code_type == "type2" + + @patch('apps.integrations.prolific.service.prolific.requests.get') + async def test_get_completion_codes_failure(self, mock_get, client: TestClient, user: User, uuid_zero: uuid.UUID): + mock_response = AsyncMock() + mock_response.status_code = 404 + mock_response.json.return_value = {"detail": "Not found"} + mock_get.return_value = mock_response + + study_id = "study123" + client.login(user) + completion_codes_url = f"/integrations/prolific/applet/{uuid_zero}/completion_codes/{study_id}" + result = await client.get(completion_codes_url) + + assert result.status_code == 404 \ No newline at end of file diff --git a/src/apps/integrations/service/integrations.py b/src/apps/integrations/service/integrations.py index a4341b040c41..a58e31d08039 100644 --- a/src/apps/integrations/service/integrations.py +++ b/src/apps/integrations/service/integrations.py @@ -55,7 +55,6 @@ async def create_integration(self, newIntegration: Integration) -> Integration: await ProlificIntegrationService( newIntegration.applet_id, self.session, - self.user, ).create_prolific_integration(api_key=newIntegration.configuration["api_key"]) return Integration( integration_type=AvailableIntegrations.PROLIFIC, diff --git a/src/infrastructure/app.py b/src/infrastructure/app.py index b38807cace0b..e7a693f82640 100644 --- a/src/infrastructure/app.py +++ b/src/infrastructure/app.py @@ -17,6 +17,7 @@ import apps.file.router as files import apps.folders.router as folders import apps.healthcheck.router as healthcheck +import apps.integrations.prolific.router as prolific import apps.integrations.loris.router as loris import apps.integrations.router as integrations import apps.invitations.router as invitations @@ -71,6 +72,7 @@ activity_assignments.router, activity_assignments.user_router, loris.router, + prolific.router, integrations.router, )