Skip to content

Commit

Permalink
Adding auth implementation of prolific client
Browse files Browse the repository at this point in the history
  • Loading branch information
JackUrb committed Jul 7, 2023
1 parent 1e8e276 commit a386277
Show file tree
Hide file tree
Showing 11 changed files with 604 additions and 295 deletions.
1 change: 1 addition & 0 deletions mephisto/abstractions/providers/prolific/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from .bonuses import Bonuses
from .eligibility_requirements import EligibilityRequirements
from .invitations import Invitations
from .messages import Messages
from .participant_groups import ParticipantGroups
from .projects import Projects
Expand Down
57 changes: 34 additions & 23 deletions mephisto/abstractions/providers/prolific/api/base_api_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.

import json
import os
from typing import Optional
from typing import Union
Expand All @@ -18,30 +19,32 @@
from .exceptions import ProlificException
from .exceptions import ProlificRequestError

BASE_URL = os.environ.get('PROLIFIC_BASE_URL', 'https://api.prolific.co/api/v1/')
CREDENTIALS_CONFIG_DIR = '~/.prolific/'
CREDENTIALS_CONFIG_PATH = os.path.join(CREDENTIALS_CONFIG_DIR, 'credentials')
BASE_URL = os.environ.get("PROLIFIC_BASE_URL", "https://api.prolific.co/api/v1/")
CREDENTIALS_CONFIG_DIR = "~/.prolific/"
CREDENTIALS_CONFIG_PATH = os.path.join(CREDENTIALS_CONFIG_DIR, "credentials")

logger = get_logger(name=__name__)


def get_prolific_api_key() -> Union[str, None]:
credentials_path = os.path.expanduser(CREDENTIALS_CONFIG_PATH)
prolific_user = os.environ.get("PROLIFIC_API_USER", "")
if os.path.exists(credentials_path):
with open(credentials_path, 'r') as f:
api_key = f.read().strip()
with open(credentials_path, "r") as f:
all_keys = json.load(f)
api_key = all_keys.get(prolific_user, None)
return api_key
return None


API_KEY = os.environ.get('PROLIFIC_API_KEY', '') or get_prolific_api_key()
API_KEY = os.environ.get("PROLIFIC_API_KEY", "") or get_prolific_api_key()


class HTTPMethod:
GET = 'get'
POST = 'post'
PATCH = 'patch'
DELETE = 'delete'
GET = "get"
POST = "post"
PATCH = "patch"
DELETE = "delete"


class BaseAPIResource(object):
Expand All @@ -55,21 +58,26 @@ def _base_request(
api_endpoint: str,
params: Optional[dict] = None,
headers: Optional[dict] = None,
api_key: Optional[str] = None,
) -> Union[dict, str, None]:
log_prefix = f'[{cls.__name__}]'
log_prefix = f"[{cls.__name__}]"

if API_KEY is None:
raise ProlificAPIKeyError
if api_key is None:
if API_KEY is None:
raise ProlificAPIKeyError
api_key = API_KEY

try:
url = urljoin(BASE_URL, api_endpoint)

headers = headers or {}
headers.update({
'Authorization': f'Token {API_KEY}',
})
headers.update(
{
"Authorization": f"Token {api_key}",
}
)

logger.debug(f'{log_prefix} {method} {url}. Params: {params}')
logger.debug(f"{log_prefix} {method} {url}. Params: {params}")

if method == HTTPMethod.GET:
response = requests.get(url, headers=headers, json=params)
Expand All @@ -83,31 +91,34 @@ def _base_request(
elif method == HTTPMethod.DELETE:
response = requests.delete(url, headers=headers, json=params)
else:
raise ProlificException('Invalid HTTP method.')
raise ProlificException("Invalid HTTP method.")

response.raise_for_status()
if response.status_code == status.HTTP_204_NO_CONTENT and not response.content:
if (
response.status_code == status.HTTP_204_NO_CONTENT
and not response.content
):
result = None
else:
result = response.json()

logger.debug(f'{log_prefix} Response: {result}')
logger.debug(f"{log_prefix} Response: {result}")

return result

except requests.exceptions.HTTPError as err:
logger.error(
f'{log_prefix} Request error: {err}. Response text: `{err.response.text}`'
f"{log_prefix} Request error: {err}. Response text: `{err.response.text}`"
)
if err.response.status_code == status.HTTP_401_UNAUTHORIZED:
raise ProlificAuthenticationError

message = err.args[0]
message = f'{message}. {err.response.text}'
message = f"{message}. {err.response.text}"
raise ProlificRequestError(message, status_code=err.response.status_code)

except Exception:
logger.exception(f'{log_prefix} Unexpected error')
logger.exception(f"{log_prefix} Unexpected error")
raise ProlificException

@classmethod
Expand Down
71 changes: 71 additions & 0 deletions mephisto/abstractions/providers/prolific/api/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env python3

# Copyright (c) Facebook, Inc. and its affiliates.
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.

from typing import Type, TypeVar, cast

from .base_api_resource import BaseAPIResource
from .bonuses import Bonuses as _Bonuses
from .eligibility_requirements import (
EligibilityRequirements as _EligibilityRequirements,
)
from .invitations import Invitations as _Invitations
from .messages import Messages as _Messages
from .participant_groups import ParticipantGroups as _ParticipantGroups
from .projects import Projects as _Projects
from .studies import Studies as _Studies
from .submissions import Submissions as _Submissions
from .users import Users as _Users
from .workspaces import Workspaces as _Workspaces

T = TypeVar("T")


def wrap_class(target_cls: Type[T], api_key: str) -> Type[T]:
"""
Create a wrapper around the given BaseAPIResource to have the
api_key pre-bound
"""
assert issubclass(target_cls, BaseAPIResource), "Can only wrap BaseAPIResource"

class Wrapper(target_cls):
@classmethod
def _base_request(cls, *args, **kwargs):
new_args = {k: v for k, v in kwargs.items()}
if new_args.get("api_key", None) is None:
new_args["api_key"] = api_key
return super()._base_request(*args, **new_args)

return cast(Type[T], Wrapper)


class ProlificClient:

Bonuses: Type[_Bonuses]
EligibilityRequirements: Type[_EligibilityRequirements]
Invitations: Type[_Invitations]
Messages: Type[_Messages]
ParticipantGroups: Type[_ParticipantGroups]
Projects: Type[_Projects]
Studies: Type[_Studies]
Submissions: Type[_Submissions]
Users: Type[_Users]
Workspaces: Type[_Workspaces]

def __init__(self, api_key: str):
"""
Creates a client that can be used to call all of the
prolific data model using the provided key.
"""
self.Bonuses = wrap_class(_Bonuses, api_key)
self.EligibilityRequirements = wrap_class(_EligibilityRequirements, api_key)
self.Invitations = wrap_class(_Invitations, api_key)
self.Messages = wrap_class(_Messages, api_key)
self.ParticipantGroups = wrap_class(_ParticipantGroups, api_key)
self.Projects = wrap_class(_Projects, api_key)
self.Studies = wrap_class(_Studies, api_key)
self.Submissions = wrap_class(_Submissions, api_key)
self.Users = wrap_class(_Users, api_key)
self.Workspaces = wrap_class(_Workspaces, api_key)
127 changes: 70 additions & 57 deletions mephisto/abstractions/providers/prolific/api/constants.py
Original file line number Diff line number Diff line change
@@ -1,102 +1,115 @@
EMAIL_FORMAT = '^\\S+@\\S+\\.\\S+$' # Simple email format checking
EMAIL_FORMAT = "^\\S+@\\S+\\.\\S+$" # Simple email format checking


# --- Studies ---

# HACK: Hardcoded Question IDs (Prolific doesn't have a better way for now)
# TODO (#1008): Make this dynamic as soon as possible
ER_AGE_RANGE_QUESTION_ID = '54ac6ea9fdf99b2204feb893'
ER_AGE_RANGE_QUESTION_ID = "54ac6ea9fdf99b2204feb893"

# https://docs.prolific.co/docs/api-docs/public/#tag/Studies/The-study-object
# `external_study_url` field
STUDY_URL_PARTICIPANT_ID_PARAM = 'participant_id'
STUDY_URL_PARTICIPANT_ID_PARAM_PROLIFIC_VAR = '{{%PROLIFIC_PID%}}'
STUDY_URL_STUDY_ID_PARAM = 'study_id'
STUDY_URL_STUDY_ID_PARAM_PROLIFIC_VAR = '{{%STUDY_ID%}}'
STUDY_URL_SUBMISSION_ID_PARAM = 'submission_id'
STUDY_URL_SUBMISSION_ID_PARAM_PROLIFIC_VAR = '{{%SESSION_ID%}}'
STUDY_URL_PARTICIPANT_ID_PARAM = "participant_id"
STUDY_URL_PARTICIPANT_ID_PARAM_PROLIFIC_VAR = "{{%PROLIFIC_PID%}}"
STUDY_URL_STUDY_ID_PARAM = "study_id"
STUDY_URL_STUDY_ID_PARAM_PROLIFIC_VAR = "{{%STUDY_ID%}}"
STUDY_URL_SUBMISSION_ID_PARAM = "submission_id"
STUDY_URL_SUBMISSION_ID_PARAM_PROLIFIC_VAR = "{{%SESSION_ID%}}"


class ProlificIDOption:
NOT_REQUIRED = 'not_required'
QUESTION = 'question'
URL_PARAMETERS = 'url_parameters'
NOT_REQUIRED = "not_required"
QUESTION = "question"
URL_PARAMETERS = "url_parameters"


class StudyAction:
AUTOMATICALLY_APPROVE = 'AUTOMATICALLY_APPROVE'
MANUALLY_REVIEW = 'MANUALLY_REVIEW'
PUBLISH = 'PUBLISH'
START = 'START'
STOP = 'STOP'
UNPUBLISHED = 'UNPUBLISHED'
AUTOMATICALLY_APPROVE = "AUTOMATICALLY_APPROVE"
MANUALLY_REVIEW = "MANUALLY_REVIEW"
PUBLISH = "PUBLISH"
START = "START"
STOP = "STOP"
UNPUBLISHED = "UNPUBLISHED"


class StudyStatus:
UNPUBLISHED = 'UNPUBLISHED'
ACTIVE = 'ACTIVE'
SCHEDULED = 'SCHEDULED'
PAUSED = 'PAUSED'
AWAITING_REVIEW = 'AWAITING REVIEW'
COMPLETED = 'COMPLETED'
_EXPIRED = 'EXPIRED' # Pseudo status that we will use in `Study.internal_name` as a hack
UNPUBLISHED = "UNPUBLISHED"
ACTIVE = "ACTIVE"
SCHEDULED = "SCHEDULED"
PAUSED = "PAUSED"
AWAITING_REVIEW = "AWAITING REVIEW"
COMPLETED = "COMPLETED"
_EXPIRED = (
"EXPIRED" # Pseudo status that we will use in `Study.internal_name` as a hack
)


class StudyCompletionOption:
CODE = 'code'
URL = 'url'
CODE = "code"
URL = "url"


class StudyCodeType:
COMPLETED = 'COMPLETED'
FAILED_ATTENTION_CHECK = 'FAILED_ATTENTION_CHECK'
FOLLOW_UP_STUDY = 'FOLLOW_UP_STUDY'
GIVE_BONUS = 'GIVE_BONUS'
INCOMPATIBLE_DEVICE = 'INCOMPATIBLE_DEVICE'
NO_CONSENT = 'NO_CONSENT'
OTHER = 'OTHER'
COMPLETED = "COMPLETED"
FAILED_ATTENTION_CHECK = "FAILED_ATTENTION_CHECK"
FOLLOW_UP_STUDY = "FOLLOW_UP_STUDY"
GIVE_BONUS = "GIVE_BONUS"
INCOMPATIBLE_DEVICE = "INCOMPATIBLE_DEVICE"
NO_CONSENT = "NO_CONSENT"
OTHER = "OTHER"


# --- Submissions ---

# It must be at least 100 chars long
DEFAULT_REJECTION_CATEGORY_MESSAGE = (
'This is default automatical rejection message '
'as Prolific requires some text at least 100 chars long.'
"This is default automatical rejection message "
"as Prolific requires some text at least 100 chars long."
)


class SubmissionStatus:
"""
Submission statuses explained
https://researcher-help.prolific.co/hc/en-gb/articles/360009094114-Submission-statuses-explained
"""
RESERVED = 'RESERVED'
ACTIVE = 'ACTIVE'
TIMED_OUT = 'TIMED-OUT'
AWAITING_REVIEW = 'AWAITING REVIEW'
APPROVED = 'APPROVED'
RETURNED = 'RETURNED'
REJECTED = 'REJECTED'

RESERVED = "RESERVED"
ACTIVE = "ACTIVE"
TIMED_OUT = "TIMED-OUT"
AWAITING_REVIEW = "AWAITING REVIEW"
APPROVED = "APPROVED"
RETURNED = "RETURNED"
REJECTED = "REJECTED"
# After you approve or reject a submission, it may have the ‘Processing’ status
# for a short time before showing as ‘Approved’ or ‘Rejected’.
PROCESSING = 'PROCESSING'
PROCESSING = "PROCESSING"


class SubmissionAction:
APPROVE = 'APPROVE'
REJECT = 'REJECT'
APPROVE = "APPROVE"
REJECT = "REJECT"


class SubmissionRejectionCategory:
BAD_CODE = 'BAD_CODE'
FAILED_CHECK = 'FAILED_CHECK'
FAILED_INSTRUCTIONS = 'FAILED_INSTRUCTIONS'
INCOMP_LONGITUDINAL = 'INCOMP_LONGITUDINAL'
LOW_EFFORT = 'LOW_EFFORT'
MALINGERING = 'MALINGERING'
NO_CODE = 'NO_CODE'
NO_DATA = 'NO_DATA'
OTHER = 'OTHER'
TOO_QUICKLY = 'TOO_QUICKLY'
TOO_SLOWLY = 'TOO_SLOWLY'
UNSUPP_DEVICE = 'UNSUPP_DEVICE'
BAD_CODE = "BAD_CODE"
FAILED_CHECK = "FAILED_CHECK"
FAILED_INSTRUCTIONS = "FAILED_INSTRUCTIONS"
INCOMP_LONGITUDINAL = "INCOMP_LONGITUDINAL"
LOW_EFFORT = "LOW_EFFORT"
MALINGERING = "MALINGERING"
NO_CODE = "NO_CODE"
NO_DATA = "NO_DATA"
OTHER = "OTHER"
TOO_QUICKLY = "TOO_QUICKLY"
TOO_SLOWLY = "TOO_SLOWLY"
UNSUPP_DEVICE = "UNSUPP_DEVICE"


# --- Workspaces ---


class WorkspaceRole:
WORKSPACE_ADMIN = "WORKSPACE_ADMIN"
WORKSPACE_COLLABORATOR = "WORKSPACE_COLLABORATOR"
PROJECT_EDITOR = "PROJECT_EDITOR"
Loading

0 comments on commit a386277

Please sign in to comment.