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
19 changes: 16 additions & 3 deletions src/apps/answers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
12 changes: 1 addition & 11 deletions src/apps/answers/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions src/apps/integrations/prolific/errors.py
Original file line number Diff line number Diff line change
@@ -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.")
19 changes: 11 additions & 8 deletions src/apps/integrations/prolific/service/prolific.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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}"
Expand Down Expand Up @@ -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"])

Expand All @@ -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()
6 changes: 6 additions & 0 deletions src/apps/users/cruds/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
1 change: 1 addition & 0 deletions src/apps/users/db/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions src/apps/users/services/prolific_user.py
Original file line number Diff line number Diff line change
@@ -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")
Comment thread
hamzace marked this conversation as resolved.

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}"
33 changes: 33 additions & 0 deletions src/apps/users/tests/unit/test_user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions src/config/prolific_respondent.py
Original file line number Diff line number Diff line change
@@ -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: "
Original file line number Diff line number Diff line change
@@ -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 ###