Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/apps/answers/domain/answers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions src/apps/answers/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}\
Comment thread
elierotenberg marked this conversation as resolved.
/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)
Expand Down
22 changes: 22 additions & 0 deletions src/apps/integrations/prolific/api.py
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 21 additions & 0 deletions src/apps/integrations/prolific/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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]
34 changes: 34 additions & 0 deletions src/apps/integrations/prolific/router.py
Original file line number Diff line number Diff line change
@@ -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)
70 changes: 61 additions & 9 deletions src/apps/integrations/prolific/service/prolific.py
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
hamzace marked this conversation as resolved.
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:
Expand All @@ -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)
Comment thread
hamzace marked this conversation as resolved.

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")
Comment thread
hamzace marked this conversation as resolved.
57 changes: 57 additions & 0 deletions src/apps/integrations/prolific/tests/test_prolific_service.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion src/apps/integrations/service/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/infrastructure/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -71,6 +72,7 @@
activity_assignments.router,
activity_assignments.user_router,
loris.router,
prolific.router,
integrations.router,
)

Expand Down