diff --git a/mephisto/abstractions/providers/prolific/api/__init__.py b/mephisto/abstractions/providers/prolific/api/__init__.py index 243cd2db4..340b8fc00 100644 --- a/mephisto/abstractions/providers/prolific/api/__init__.py +++ b/mephisto/abstractions/providers/prolific/api/__init__.py @@ -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 diff --git a/mephisto/abstractions/providers/prolific/api/base_api_resource.py b/mephisto/abstractions/providers/prolific/api/base_api_resource.py index 15825ac8a..73ddff218 100644 --- a/mephisto/abstractions/providers/prolific/api/base_api_resource.py +++ b/mephisto/abstractions/providers/prolific/api/base_api_resource.py @@ -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 @@ -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): @@ -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) @@ -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 diff --git a/mephisto/abstractions/providers/prolific/api/client.py b/mephisto/abstractions/providers/prolific/api/client.py new file mode 100644 index 000000000..11813ecec --- /dev/null +++ b/mephisto/abstractions/providers/prolific/api/client.py @@ -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) diff --git a/mephisto/abstractions/providers/prolific/api/constants.py b/mephisto/abstractions/providers/prolific/api/constants.py index f9c959558..a4673df84 100644 --- a/mephisto/abstractions/providers/prolific/api/constants.py +++ b/mephisto/abstractions/providers/prolific/api/constants.py @@ -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" diff --git a/mephisto/abstractions/providers/prolific/api/invitations.py b/mephisto/abstractions/providers/prolific/api/invitations.py new file mode 100644 index 000000000..42a60dc81 --- /dev/null +++ b/mephisto/abstractions/providers/prolific/api/invitations.py @@ -0,0 +1,30 @@ +#!/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 .base_api_resource import BaseAPIResource +from .constants import WorkspaceRole + +from typing import List + + +class Invitations(BaseAPIResource): + invitations_api_endpoint = "invitations/" + + @classmethod + def create( + cls, + workspace_id: str, + collaborators: List[str], + role: str = WorkspaceRole.WORKSPACE_ADMIN, + ) -> dict: + endpoint = cls.invitations_api_endpoint + params = { + "association": workspace_id, + "emails": collaborators, + "role": role, + } + response_json = cls.post(endpoint, params=params) + return response_json diff --git a/mephisto/abstractions/providers/prolific/api/workspaces.py b/mephisto/abstractions/providers/prolific/api/workspaces.py index 030fd8065..ca98734a2 100644 --- a/mephisto/abstractions/providers/prolific/api/workspaces.py +++ b/mephisto/abstractions/providers/prolific/api/workspaces.py @@ -12,14 +12,14 @@ class Workspaces(BaseAPIResource): - list_api_endpoint = 'workspaces/' - retrieve_api_endpoint = 'workspaces/{id}/' - get_balance_api_endpoint = 'workspaces/{id}/balance/' + list_api_endpoint = "workspaces/" + retrieve_api_endpoint = "workspaces/{id}/" + get_balance_api_endpoint = "workspaces/{id}/balance/" @classmethod def list(cls) -> List[Workspace]: response_json = cls.get(cls.list_api_endpoint) - workspaces = [Workspace(**s) for s in response_json['results']] + workspaces = [Workspace(**s) for s in response_json["results"]] return workspaces @classmethod @@ -35,6 +35,18 @@ def create(cls, **data) -> Workspace: response_json = cls.post(cls.list_api_endpoint, params=workspace.to_dict()) return Workspace(**response_json) + @classmethod + def update(cls, new_workspace: Workspace) -> Workspace: + endpoint = cls.retrieve_api_endpoint.format(id=new_workspace.id) + new_workspace.validate() + print(new_workspace.users) + params = new_workspace.to_dict() + if params["description"] == "": + del params["description"] + del params["product"] + response_json = cls.patch(endpoint, params=new_workspace.to_dict()) + return Workspace(**response_json) + @classmethod def get_balance(cls, id: str) -> WorkspaceBalance: endpoint = cls.get_balance_api_endpoint.format(id=id) diff --git a/mephisto/abstractions/providers/prolific/prolific_agent.py b/mephisto/abstractions/providers/prolific/prolific_agent.py index ca06464e9..79fdd2e7f 100644 --- a/mephisto/abstractions/providers/prolific/prolific_agent.py +++ b/mephisto/abstractions/providers/prolific/prolific_agent.py @@ -16,11 +16,15 @@ from mephisto.abstractions.providers.prolific.provider_type import PROVIDER_TYPE from mephisto.data_model.agent import Agent from mephisto.utils.logger_core import get_logger -from . import api as prolific_api +from .api.client import ProlificClient if TYPE_CHECKING: - from mephisto.abstractions.providers.prolific.prolific_datastore import ProlificDatastore - from mephisto.abstractions.providers.prolific.prolific_requester import ProlificRequester + from mephisto.abstractions.providers.prolific.prolific_datastore import ( + ProlificDatastore, + ) + from mephisto.abstractions.providers.prolific.prolific_requester import ( + ProlificRequester, + ) from mephisto.abstractions.providers.prolific.prolific_unit import ProlificUnit from mephisto.abstractions.providers.prolific.prolific_worker import ProlificWorker from mephisto.data_model.unit import Unit @@ -42,33 +46,37 @@ class ProlificAgent(Agent): def __init__( self, - db: 'MephistoDB', + db: "MephistoDB", db_id: str, row: Optional[Mapping[str, Any]] = None, _used_new_call: bool = False, ): super().__init__(db, db_id, row=row, _used_new_call=_used_new_call) - self.datastore: 'ProlificDatastore' = db.get_datastore_for_provider(self.PROVIDER_TYPE) - self.unit: 'ProlificUnit' = cast('ProlificUnit', self.get_unit()) - self.worker: 'ProlificWorker' = cast('ProlificWorker', self.get_worker()) + self.datastore: "ProlificDatastore" = db.get_datastore_for_provider( + self.PROVIDER_TYPE + ) + self.unit: "ProlificUnit" = cast("ProlificUnit", self.get_unit()) + self.worker: "ProlificWorker" = cast("ProlificWorker", self.get_worker()) - def _get_client(self) -> prolific_api: + def _get_client(self) -> ProlificClient: """Get a Prolific client""" - requester: 'ProlificRequester' = cast('ProlificRequester', self.unit.get_requester()) + requester: "ProlificRequester" = cast( + "ProlificRequester", self.unit.get_requester() + ) return self.datastore.get_client_for_requester(requester.requester_name) @property def log_prefix(self) -> str: - return f'[Agent {self.db_id}] ' + return f"[Agent {self.db_id}] " @classmethod def new_from_provider_data( cls, - db: 'MephistoDB', - worker: 'Worker', - unit: 'Unit', + db: "MephistoDB", + worker: "Worker", + unit: "Unit", provider_data: Dict[str, Any], - ) -> 'Agent': + ) -> "Agent": """ Wrapper around the new method that allows registering additional bookkeeping information from a crowd provider for this agent @@ -76,48 +84,54 @@ def new_from_provider_data( from mephisto.abstractions.providers.prolific.prolific_unit import ProlificUnit logger.debug( - f'Registering Prolific Submission in datastore from Prolific. Data: {provider_data}' + f"Registering Prolific Submission in datastore from Prolific. Data: {provider_data}" ) assert isinstance( unit, ProlificUnit - ), 'Can only register Prolific agents to Prolific units' + ), "Can only register Prolific agents to Prolific units" - prolific_study_id = provider_data['prolific_study_id'] - prolific_submission_id = provider_data['assignment_id'] + prolific_study_id = provider_data["prolific_study_id"] + prolific_submission_id = provider_data["assignment_id"] unit.register_from_provider_data(prolific_study_id, prolific_submission_id) - logger.debug('Prolific Submission has been registered successfully') + logger.debug("Prolific Submission has been registered successfully") return super().new_from_provider_data(db, worker, unit, provider_data) def approve_work(self) -> None: """Approve the work done on this specific Unit""" - logger.debug(f'{self.log_prefix}Approving work') + logger.debug(f"{self.log_prefix}Approving work") if self.get_status() == AgentState.STATUS_APPROVED: - logger.info(f'{self.log_prefix}Approving already approved agent {self}, skipping') + logger.info( + f"{self.log_prefix}Approving already approved agent {self}, skipping" + ) return client = self._get_client() prolific_study_id = self.unit.get_prolific_study_id() worker_id = self.worker.get_prolific_worker_id() - prolific_utils.approve_work(client, study_id=prolific_study_id, worker_id=worker_id) + prolific_utils.approve_work( + client, study_id=prolific_study_id, worker_id=worker_id + ) logger.debug( - f'{self.log_prefix}' + f"{self.log_prefix}" f'Work for Study "{prolific_study_id}" completed by worker "{worker_id}" ' - f'has been approved' + f"has been approved" ) self.update_status(AgentState.STATUS_APPROVED) def reject_work(self, reason) -> None: """Reject the work done on this specific Unit""" - logger.debug(f'{self.log_prefix}Rejecting work') + logger.debug(f"{self.log_prefix}Rejecting work") if self.get_status() == AgentState.STATUS_APPROVED: - logger.warning(f'{self.log_prefix}Cannot reject {self}, it is already approved') + logger.warning( + f"{self.log_prefix}Cannot reject {self}, it is already approved" + ) return client = self._get_client() @@ -126,8 +140,11 @@ def reject_work(self, reason) -> None: # TODO (#1008): remove this suppression of exception when Prolific fixes their API from .api.exceptions import ProlificException + try: - prolific_utils.reject_work(client, study_id=prolific_study_id, worker_id=worker_id) + prolific_utils.reject_work( + client, study_id=prolific_study_id, worker_id=worker_id + ) except ProlificException: logger.info( "NOTE: ignore the above error - " @@ -135,9 +152,9 @@ def reject_work(self, reason) -> None: ) logger.debug( - f'{self.log_prefix}' + f"{self.log_prefix}" f'Work for Study "{prolific_study_id}" completed by worker "{worker_id}" ' - f'has been rejected. Reason: {reason}' + f"has been rejected. Reason: {reason}" ) self.update_status(AgentState.STATUS_REJECTED) @@ -148,12 +165,14 @@ def mark_done(self) -> None: is marked as done there's nothing else we need to do as the task has been submitted. """ - logger.debug(f'{self.log_prefix}Work has been marked as Done') + logger.debug(f"{self.log_prefix}Work has been marked as Done") if self.get_status() != AgentState.STATUS_DISCONNECT: - self.db.update_agent(agent_id=self.db_id, status=AgentState.STATUS_COMPLETED) + self.db.update_agent( + agent_id=self.db_id, status=AgentState.STATUS_COMPLETED + ) @staticmethod - def new(db: 'MephistoDB', worker: 'Worker', unit: 'Unit') -> 'Agent': + def new(db: "MephistoDB", worker: "Worker", unit: "Unit") -> "Agent": """Create an agent for this worker to be used for work on the given Unit.""" return ProlificAgent._register_agent(db, worker, unit, PROVIDER_TYPE) diff --git a/mephisto/abstractions/providers/prolific/prolific_datastore.py b/mephisto/abstractions/providers/prolific/prolific_datastore.py index 83db075c2..51504d3ee 100644 --- a/mephisto/abstractions/providers/prolific/prolific_datastore.py +++ b/mephisto/abstractions/providers/prolific/prolific_datastore.py @@ -16,8 +16,9 @@ from mephisto.abstractions.databases.local_database import is_unique_failure from mephisto.abstractions.providers.prolific.provider_type import PROVIDER_TYPE from mephisto.utils.logger_core import get_logger -from . import api as prolific_api from . import prolific_datastore_tables as tables +from .api.client import ProlificClient +from .prolific_utils import get_authenticated_client logger = get_logger(name=__name__) @@ -25,7 +26,7 @@ class ProlificDatastore: def __init__(self, datastore_root: str): """Initialize local storage of active agents, connect to the database""" - self.session_storage: Dict[str, Any] = {} # TODO (#1008): Implement type + self.session_storage: Dict[str, ProlificClient] = {} self.agent_data: Dict[str, Dict[str, Any]] = {} self.table_access_condition = threading.Condition() self.conn: Dict[int, sqlite3.Connection] = {} @@ -58,7 +59,7 @@ def init_tables(self) -> None: """Run all the table creation SQL queries to ensure the expected tables exist""" with self.table_access_condition: conn = self._get_connection() - conn.execute('PRAGMA foreign_keys = 1') + conn.execute("PRAGMA foreign_keys = 1") c = conn.cursor() c.execute(tables.CREATE_STUDIES_TABLE) c.execute(tables.CREATE_SUBMISSIONS_TABLE) @@ -127,7 +128,7 @@ def get_unassigned_study_ids(self, run_id: str): (run_id,), ) results = c.fetchall() - return [r['prolific_study_id'] for r in results] + return [r["prolific_study_id"] for r in results] def register_submission_to_study( self, @@ -140,9 +141,9 @@ def register_submission_to_study( or clear the assignment after a return """ logger.debug( - f'Attempting to assign Study {prolific_study_id}, ' - f'Unit {unit_id}, ' - f'Submission {prolific_submission_id}.' + f"Attempting to assign Study {prolific_study_id}, " + f"Unit {unit_id}, " + f"Submission {prolific_submission_id}." ) with self.table_access_condition, self._get_connection() as conn: c = conn.cursor() @@ -329,25 +330,27 @@ def get_unit_expired(self, unit_id: str) -> bool: results = c.fetchall() return bool(results[0]["is_expired"]) - def get_session_for_requester(self, requester_name: str) -> prolific_api: + def get_session_for_requester(self, requester_name: str) -> ProlificClient: """ Either create a new session for the given requester or return the existing one if it has already been created """ if requester_name not in self.session_storage: - session = prolific_api + session = get_authenticated_client(requester_name) self.session_storage[requester_name] = session return self.session_storage[requester_name] - def get_client_for_requester(self, requester_name: str) -> prolific_api: + def get_client_for_requester(self, requester_name: str) -> ProlificClient: """ Return the client for the given requester, which should allow direct calls to the Prolific surface """ return self.get_session_for_requester(requester_name) - def get_qualification_mapping(self, qualification_name: str) -> Optional[sqlite3.Row]: + def get_qualification_mapping( + self, qualification_name: str + ) -> Optional[sqlite3.Row]: """Get the mapping between Mephisto qualifications and Prolific Participant Group""" with self.table_access_condition: conn = self._get_connection() @@ -407,28 +410,30 @@ def create_qualification_mapping( db_qualification = self.get_qualification_mapping(qualification_name) logger.debug( - f'Multiple Prolific mapping creations ' + f"Multiple Prolific mapping creations " f'for qualification "{qualification_name}". ' - f'Found existing one: {db_qualification}. ' + f"Found existing one: {db_qualification}. " ) - assert \ - db_qualification is not None, \ - 'Cannot be none given is_unique_failure on insert' + assert ( + db_qualification is not None + ), "Cannot be none given is_unique_failure on insert" - db_requester_id = db_qualification['requester_id'] - db_prolific_qualification_name = db_qualification['prolific_participant_group_name'] + db_requester_id = db_qualification["requester_id"] + db_prolific_qualification_name = db_qualification[ + "prolific_participant_group_name" + ] if db_requester_id != requester_id: logger.warning( - f'Prolific Qualification mapping create for {qualification_name} ' - f'under requester {requester_id}, already exists under {db_requester_id}.' + f"Prolific Qualification mapping create for {qualification_name} " + f"under requester {requester_id}, already exists under {db_requester_id}." ) if db_prolific_qualification_name != prolific_participant_group_name: logger.warning( - f'Prolific Qualification mapping create for {qualification_name} ' - f'with Prolific name {prolific_participant_group_name}, ' - f'already exists under {db_prolific_qualification_name}.' + f"Prolific Qualification mapping create for {qualification_name} " + f"with Prolific name {prolific_participant_group_name}, " + f"already exists under {db_prolific_qualification_name}." ) return None @@ -455,11 +460,11 @@ def clear_study_from_unit(self, unit_id: str) -> None: return if len(results) > 1: logger.warning( - 'WARNING - UNIT HAD MORE THAN ONE STUDY MAPPED TO IT!', + "WARNING - UNIT HAD MORE THAN ONE STUDY MAPPED TO IT!", unit_id, [dict(r) for r in results], ) - result_study_id = results[0]['prolific_study_id'] + result_study_id = results[0]["prolific_study_id"] c.execute( """ UPDATE units @@ -493,7 +498,9 @@ def register_run( prolific_project_id: str, prolific_study_config_path: str, frame_height: int = 0, - prolific_study_id: Optional[str] = None, # TODO (#1008): Remove it. Leave this just in case + prolific_study_id: Optional[ + str + ] = None, # TODO (#1008): Remove it. Leave this just in case ) -> None: """Register a new task run in the Task Runs table""" with self.table_access_condition, self._get_connection() as conn: diff --git a/mephisto/abstractions/providers/prolific/prolific_provider.py b/mephisto/abstractions/providers/prolific/prolific_provider.py index 864b6ae79..955944b6b 100644 --- a/mephisto/abstractions/providers/prolific/prolific_provider.py +++ b/mephisto/abstractions/providers/prolific/prolific_provider.py @@ -18,14 +18,18 @@ from mephisto.abstractions.providers.prolific import prolific_utils from mephisto.abstractions.providers.prolific.api.constants import ProlificIDOption from mephisto.abstractions.providers.prolific.prolific_agent import ProlificAgent -from mephisto.abstractions.providers.prolific.prolific_datastore import ProlificDatastore -from mephisto.abstractions.providers.prolific.prolific_requester import ProlificRequester +from mephisto.abstractions.providers.prolific.prolific_datastore import ( + ProlificDatastore, +) +from mephisto.abstractions.providers.prolific.prolific_requester import ( + ProlificRequester, +) from mephisto.abstractions.providers.prolific.prolific_unit import ProlificUnit from mephisto.abstractions.providers.prolific.prolific_worker import ProlificWorker from mephisto.abstractions.providers.prolific.provider_type import PROVIDER_TYPE from mephisto.operations.registry import register_mephisto_abstraction from mephisto.utils.logger_core import get_logger -from . import api as prolific_api +from .api.client import ProlificClient from .api.data_models import Project from .api.data_models import Study from .api.data_models import Workspace @@ -42,10 +46,10 @@ DEFAULT_FRAME_HEIGHT = 0 -DEFAULT_PROLIFIC_GROUP_NAME_ALLOW_LIST = 'Allow list' -DEFAULT_PROLIFIC_GROUP_NAME_BLOCK_LIST = 'Block list' -DEFAULT_PROLIFIC_PROJECT_NAME = 'Project' -DEFAULT_PROLIFIC_WORKSPACE_NAME = 'My Workspace' +DEFAULT_PROLIFIC_GROUP_NAME_ALLOW_LIST = "Allow list" +DEFAULT_PROLIFIC_GROUP_NAME_BLOCK_LIST = "Block list" +DEFAULT_PROLIFIC_PROJECT_NAME = "Project" +DEFAULT_PROLIFIC_WORKSPACE_NAME = "My Workspace" logger = get_logger(name=__name__) @@ -54,62 +58,63 @@ @dataclass class ProlificProviderArgs(ProviderArgs): """Base class for arguments to configure Crowd Providers""" + _provider_type: str = PROVIDER_TYPE requester_name: str = PROVIDER_TYPE # This link is being collected automatically for EC2 archidect. prolific_external_study_url: str = field( - default='', + default="", metadata={ - 'help': ( - 'The external study URL of your study that you want participants to be direct to. ' - 'The URL can be customized to add information to match participants ' - 'in your survey. ' - 'You can add query parameters with the following placeholders. ' - 'Example of a link with params: ' - 'https://example.com?' - 'participant_id={{%PROLIFIC_PID%}}' - '&study_id={{%STUDY_ID%}}' - '&submission_id={{%SESSION_ID%}}' - 'where `prolific_pid`, `study_id`, `submission_id` are params we use on our side, ' - 'and `{{%PROLIFIC_PID%}}`, `{{%STUDY_ID%}}`, `{{%SESSION_ID%}}` are their ' - 'format of template variables they use to replace with their IDs' + "help": ( + "The external study URL of your study that you want participants to be direct to. " + "The URL can be customized to add information to match participants " + "in your survey. " + "You can add query parameters with the following placeholders. " + "Example of a link with params: " + "https://example.com?" + "participant_id={{%PROLIFIC_PID%}}" + "&study_id={{%STUDY_ID%}}" + "&submission_id={{%SESSION_ID%}}" + "where `prolific_pid`, `study_id`, `submission_id` are params we use on our side, " + "and `{{%PROLIFIC_PID%}}`, `{{%STUDY_ID%}}`, `{{%SESSION_ID%}}` are their " + "format of template variables they use to replace with their IDs" ), }, ) prolific_id_option: str = field( default=ProlificIDOption.URL_PARAMETERS, metadata={ - 'help': ( + "help": ( 'Enum: "question" "url_parameters" "not_required". ' - 'Use \'question\' if you will add a question in your survey or ' - 'experiment asking the participant ID. ' - 'Recommended Use \'url_parameters\' if your survey or experiment can retrieve and ' - 'store those parameters for your analysis.' - 'Use \'not_required\' if you don\'t need to record them' + "Use 'question' if you will add a question in your survey or " + "experiment asking the participant ID. " + "Recommended Use 'url_parameters' if your survey or experiment can retrieve and " + "store those parameters for your analysis." + "Use 'not_required' if you don't need to record them" ), }, ) prolific_estimated_completion_time_in_minutes: int = field( default=5, metadata={ - 'help': ( - 'Estimated duration in minutes of the experiment or survey ' - '(`estimated_completion_time` in Prolific).' + "help": ( + "Estimated duration in minutes of the experiment or survey " + "(`estimated_completion_time` in Prolific)." ), }, ) prolific_total_available_places: int = field( default=1, metadata={ - 'help': 'How many participants are you looking to recruit.', + "help": "How many participants are you looking to recruit.", }, ) prolific_eligibility_requirements: list = field( default=(), metadata={ - 'help': ( - 'Eligibility requirements allows you to define ' - 'participants criteria such as age, gender and country. ' + "help": ( + "Eligibility requirements allows you to define " + "participants criteria such as age, gender and country. " ), }, ) @@ -133,6 +138,7 @@ class ProlificProvider(CrowdProvider): Prolific implementation of a CrowdProvider that stores everything in a local state in the class for use in tests. """ + UnitClass: ClassVar[Type["Unit"]] = ProlificUnit RequesterClass: ClassVar[Type["Requester"]] = ProlificRequester @@ -152,55 +158,65 @@ def initialize_provider_datastore(self, storage_path: str) -> Any: @property def log_prefix(self) -> str: - return '[Prolific Provider] ' + return "[Prolific Provider] " - def _get_client(self, requester_name: str) -> prolific_api: + def _get_client(self, requester_name: str) -> ProlificClient: """Get a Prolific client""" return self.datastore.get_client_for_requester(requester_name) def setup_resources_for_task_run( self, - task_run: 'TaskRun', - args: 'DictConfig', - shared_state: 'SharedTaskState', + task_run: "TaskRun", + args: "DictConfig", + shared_state: "SharedTaskState", server_url: str, ) -> None: - requester = cast('ProlificRequester', task_run.get_requester()) + requester = cast("ProlificRequester", task_run.get_requester()) client = self._get_client(requester.requester_name) task_run_id = task_run.db_id # Set up Task Run config config_dir = os.path.join(self.datastore.datastore_root, task_run_id) - frame_height = task_run.get_blueprint().get_frontend_args().get( - 'frame_height', DEFAULT_FRAME_HEIGHT, + frame_height = ( + task_run.get_blueprint() + .get_frontend_args() + .get( + "frame_height", + DEFAULT_FRAME_HEIGHT, + ) ) # Get Prolific specific data to create a task - prolific_workspace: Workspace = prolific_utils.find_or_create_prolific_workspace( - client, title=args.provider.prolific_workspace_name, + prolific_workspace: Workspace = ( + prolific_utils.find_or_create_prolific_workspace( + client, + title=args.provider.prolific_workspace_name, + ) ) prolific_project: Project = prolific_utils.find_or_create_prolific_project( - client, prolific_workspace.id, title=args.provider.prolific_project_name, + client, + prolific_workspace.id, + title=args.provider.prolific_project_name, ) # Create Study - logger.debug(f'{self.log_prefix}Creating Prolific Study') + logger.debug(f"{self.log_prefix}Creating Prolific Study") prolific_study: Study = prolific_utils.create_study( client, task_run_config=args, prolific_project_id=prolific_project.id, ) logger.debug( - f'{self.log_prefix}' - f'Prolific Study has been created successfully with ID: {prolific_study.id}' + f"{self.log_prefix}" + f"Prolific Study has been created successfully with ID: {prolific_study.id}" ) # Publish Prolific Study - logger.debug(f'{self.log_prefix}Publishing Prolific Study') + logger.debug(f"{self.log_prefix}Publishing Prolific Study") prolific_utils.publish_study(client, prolific_study.id) logger.debug( - f'{self.log_prefix}' + f"{self.log_prefix}" f'Prolific Study "{prolific_study.id}" has been published successfully with ID' ) @@ -218,14 +234,17 @@ def setup_resources_for_task_run( self.datastore.new_study( prolific_study_id=prolific_study.id, study_link=prolific_study.external_study_url, - duration_in_seconds=args.provider.prolific_estimated_completion_time_in_minutes * 60, + duration_in_seconds=args.provider.prolific_estimated_completion_time_in_minutes + * 60, run_id=task_run_id, ) logger.debug( f'{self.log_prefix}Prolific Study "{prolific_study.id}" has been saved into datastore' ) - def cleanup_resources_from_task_run(self, task_run: 'TaskRun', server_url: str) -> None: + def cleanup_resources_from_task_run( + self, task_run: "TaskRun", server_url: str + ) -> None: """No cleanup necessary for task type""" pass @@ -235,7 +254,7 @@ def get_wrapper_js_path(cls): Return the path to the `wrap_crowd_source.js` file for this provider to be deployed to the server """ - return os.path.join(os.path.dirname(__file__), 'wrap_crowd_source.js') + return os.path.join(os.path.dirname(__file__), "wrap_crowd_source.js") def cleanup_qualification(self, qualification_name: str) -> None: """Remove the qualification from Prolific (Participant Group), if it exists""" @@ -243,12 +262,13 @@ def cleanup_qualification(self, qualification_name: str) -> None: if mapping is None: return None - requester_id = mapping['requester_id'] + requester_id = mapping["requester_id"] requester = Requester.get(self.db, requester_id) - assert isinstance(requester, ProlificRequester), 'Must be an Prolific requester' + assert isinstance(requester, ProlificRequester), "Must be an Prolific requester" client = requester._get_client(requester.requester_name) try: - prolific_utils.delete_qualification(client, mapping['prolific_participant_group_id']) + prolific_utils.delete_qualification( + client, mapping["prolific_participant_group_id"] + ) except ProlificException: - logger.exception('Could not delete qualification on Prolific') - + logger.exception("Could not delete qualification on Prolific") diff --git a/mephisto/abstractions/providers/prolific/prolific_requester.py b/mephisto/abstractions/providers/prolific/prolific_requester.py index 1f3ece920..409aed38b 100644 --- a/mephisto/abstractions/providers/prolific/prolific_requester.py +++ b/mephisto/abstractions/providers/prolific/prolific_requester.py @@ -17,12 +17,14 @@ from mephisto.abstractions.providers.prolific import prolific_utils from mephisto.data_model.requester import Requester from mephisto.data_model.requester import RequesterArgs -from . import api as prolific_api +from .api.client import ProlificClient from .provider_type import PROVIDER_TYPE if TYPE_CHECKING: from mephisto.abstractions.database import MephistoDB - from mephisto.abstractions.providers.prolific.prolific_datastore import ProlificDatastore + from mephisto.abstractions.providers.prolific.prolific_datastore import ( + ProlificDatastore, + ) MAX_QUALIFICATION_ATTEMPTS = 300 @@ -30,17 +32,17 @@ @dataclass class ProlificRequesterArgs(RequesterArgs): name: str = field( - default='prolific', + default="prolific", metadata={ - 'help': 'Name for the requester in the Mephisto DB.', - 'required': False, + "help": "Name for the requester in the Mephisto DB.", + "required": False, }, ) api_key: str = field( default=MISSING, metadata={ - 'help': 'Prolific API key.', - 'required': True, + "help": "Prolific API key.", + "required": True, }, ) @@ -62,9 +64,11 @@ def __init__( _used_new_call: bool = False, ): super().__init__(db, db_id, row=row, _used_new_call=_used_new_call) - self.datastore: "ProlificDatastore" = db.get_datastore_for_provider(PROVIDER_TYPE) + self.datastore: "ProlificDatastore" = db.get_datastore_for_provider( + PROVIDER_TYPE + ) - def _get_client(self, requester_name: str) -> prolific_api: + def _get_client(self, requester_name: str) -> ProlificClient: """Get a Prolific client""" return self.datastore.get_client_for_requester(requester_name) @@ -74,18 +78,22 @@ def register(self, args: Optional[ProlificRequesterArgs] = None) -> None: def is_registered(self) -> bool: """Return whether this requester has registered yet""" - return prolific_utils.check_credentials(self.requester_name) + return prolific_utils.check_credentials(self._get_client(self.requester_name)) def get_available_budget(self) -> float: + client = self._get_client(self.requester_name) unit = self.db.find_units(requester_id=self.db_id)[0] task_run = unit.get_task_run() task_run_args = task_run.args balance = prolific_utils.check_balance( + client, workspace_name=task_run_args.provider.prolific_workspace_name, ) return balance - def create_new_qualification(self, prolific_project_id: str, qualification_name: str) -> str: + def create_new_qualification( + self, prolific_project_id: str, qualification_name: str + ) -> str: """ Create a new qualification (Prolific Participant Group) on Prolific owned by the requester provided @@ -93,14 +101,18 @@ def create_new_qualification(self, prolific_project_id: str, qualification_name: client = self._get_client(self.requester_name) _qualification_name = qualification_name qualification = prolific_utils.find_or_create_qualification( - client, prolific_project_id, qualification_name, + client, + prolific_project_id, + qualification_name, ) if qualification is None: # Try to append time to make the qualification unique _qualification_name = f"{qualification_name}_{time.time()}" qualification = prolific_utils.find_or_create_qualification( - client, prolific_project_id, _qualification_name, + client, + prolific_project_id, + _qualification_name, ) attempts = 0 @@ -108,7 +120,9 @@ def create_new_qualification(self, prolific_project_id: str, qualification_name: # Append something somewhat random _qualification_name = f"{qualification_name}_{str(uuid4())}" qualification = prolific_utils.find_or_create_qualification( - client, prolific_project_id, _qualification_name, + client, + prolific_project_id, + _qualification_name, ) attempts += 1 if attempts > MAX_QUALIFICATION_ATTEMPTS: diff --git a/mephisto/abstractions/providers/prolific/prolific_utils.py b/mephisto/abstractions/providers/prolific/prolific_utils.py index 79b966760..51701f94f 100644 --- a/mephisto/abstractions/providers/prolific/prolific_utils.py +++ b/mephisto/abstractions/providers/prolific/prolific_utils.py @@ -5,8 +5,10 @@ # LICENSE file in the root directory of this source tree. from jsonschema.exceptions import ValidationError +import json import os import uuid +from typing import cast from typing import List from typing import Optional from typing import Tuple @@ -21,6 +23,7 @@ from .api import eligibility_requirement_classes from .api.base_api_resource import CREDENTIALS_CONFIG_DIR from .api.base_api_resource import CREDENTIALS_CONFIG_PATH +from .api.client import ProlificClient from .api.data_models import BonusPayments from .api.data_models import ListSubmission from .api.data_models import Participant @@ -35,25 +38,55 @@ logger = get_logger(name=__name__) +DEFAULT_CLIENT = cast(ProlificClient, prolific_api) -def check_credentials(*args, **kwargs) -> bool: + +def check_credentials(client: ProlificClient = DEFAULT_CLIENT) -> bool: """Check whether API KEY is correct""" try: # Make a simple request to the API - prolific_api.Users.me() + client.Users.me() return True except (ProlificException, ValidationError): return False +def get_authenticated_client(profile_name: str) -> ProlificClient: + """Get a client for the given profile""" + cred_path = os.path.expanduser(CREDENTIALS_CONFIG_PATH) + assert os.path.exists(cred_path), f"No credentials file at {cred_path}" + with open(cred_path) as cred_file: + curr_creds = json.load(cred_file) + assert profile_name in curr_creds, f"No stored credentials for {profile_name}" + key = curr_creds[profile_name] + + return ProlificClient(api_key=key) + + def setup_credentials( - profile_name: str, register_args: Optional[ProlificRequesterArgs], + profile_name: str, + register_args: Optional[ProlificRequesterArgs], ) -> bool: + if register_args is None: + api_key = input(f"Provide api key for {profile_name}: ") + else: + api_key = register_args.api_key + if not os.path.exists(os.path.expanduser(CREDENTIALS_CONFIG_DIR)): os.mkdir(os.path.expanduser(CREDENTIALS_CONFIG_DIR)) - with open(os.path.expanduser(CREDENTIALS_CONFIG_PATH), 'w') as f: - f.write(register_args.api_key) + cred_path = os.path.expanduser(CREDENTIALS_CONFIG_PATH) + + if os.path.exists(cred_path): + with open(cred_path) as cred_file: + curr_creds = json.load(cred_file) + else: + curr_creds = {} + + curr_creds[profile_name] = api_key + + with open(os.path.expanduser(CREDENTIALS_CONFIG_PATH), "w") as cred_file: + json.dump(curr_creds, cred_file) return True @@ -62,7 +95,7 @@ def _get_eligibility_requirements(task_run_config_value: List[dict]) -> List[dic eligibility_requirements = [] for conf_eligibility_requirement in task_run_config_value: - name = conf_eligibility_requirement.get('name') + name = conf_eligibility_requirement.get("name") if cls := getattr(eligibility_requirement_classes, name, None): cls_kwargs = {} @@ -74,29 +107,33 @@ def _get_eligibility_requirements(task_run_config_value: List[dict]) -> List[dic return eligibility_requirements -def check_balance(*args, **kwargs) -> Union[float, int, None]: +def check_balance(client: ProlificClient, **kwargs) -> Union[float, int, None]: """Checks to see if there is at least available_balance amount in the workspace""" - workspace_name = kwargs.get('workspace_name') + workspace_name = kwargs.get("workspace_name") if not workspace_name: return None - found_workspace, workspace = _find_prolific_workspace(prolific_api, title=workspace_name) + found_workspace, workspace = _find_prolific_workspace(client, title=workspace_name) if not found_workspace: - logger.error(f'Could not find a workspace with name {workspace_name}') + logger.error(f"Could not find a workspace with name {workspace_name}") return None try: - workspace_ballance: WorkspaceBalance = prolific_api.Workspaces.get_balance(id=workspace.id) + workspace_balance: WorkspaceBalance = client.Workspaces.get_balance( + id=workspace.id + ) except (ProlificException, ValidationError): - logger.exception(f'Could not receive a workspace balance with {workspace.id=}') + logger.exception(f"Could not receive a workspace balance with {workspace.id=}") raise - return workspace_ballance.available_balance + return workspace_balance.available_balance def _find_prolific_workspace( - client: prolific_api, title: str, id: Optional[str] = None, + client: ProlificClient, + title: str, + id: Optional[str] = None, ) -> Tuple[bool, Optional[Workspace]]: """Find a Prolific Workspace by title or ID""" if id: @@ -104,25 +141,27 @@ def _find_prolific_workspace( workspace: Workspace = client.Workspaces.retrieve(id) return True, workspace except (ProlificException, ValidationError): - logger.exception(f'Could not find a workspace by id {id}') + logger.exception(f"Could not find a workspace by id {id}") raise try: workspaces: List[Workspace] = client.Workspaces.list() except (ProlificException, ValidationError): - logger.exception(f'Could not find a workspace by title {title}') + logger.exception(f"Could not find a workspace by title {title}") raise for workspace in workspaces: if workspace.title == title: return True, workspace - return True, None + return False, None def find_or_create_prolific_workspace( - client: prolific_api, title: str, id: Optional[str] = None, -) -> Optional[Workspace]: + client: ProlificClient, + title: str, + id: Optional[str] = None, +) -> Workspace: """Find or create a Prolific Workspace by title or ID""" found_workspace, workspace = _find_prolific_workspace(client, title, id) @@ -139,7 +178,10 @@ def find_or_create_prolific_workspace( def _find_prolific_project( - client: prolific_api, workspace_id: str, title: str, id: Optional[str] = None, + client: ProlificClient, + workspace_id: str, + title: str, + id: Optional[str] = None, ) -> Tuple[bool, Optional[Project]]: """Find a Prolific Project by title or ID""" try: @@ -158,7 +200,10 @@ def _find_prolific_project( def find_or_create_prolific_project( - client: prolific_api, workspace_id: str, title: str, id: Optional[str] = None, + client: ProlificClient, + workspace_id: str, + title: str, + id: Optional[str] = None, ) -> Optional[Project]: """Find or create a Prolific Workspace by title or ID""" found_project, project = _find_prolific_project(client, workspace_id, title, id) @@ -178,7 +223,7 @@ def find_or_create_prolific_project( return project -def delete_qualification(client: prolific_api, id: str) -> bool: +def delete_qualification(client: ProlificClient, id: str) -> bool: """ Delete a qualification (Prolific Participant Group) by ID :param id: Prolific Participant Group's ID @@ -188,7 +233,7 @@ def delete_qualification(client: prolific_api, id: str) -> bool: def _find_qualification( - client: prolific_api, + client: ProlificClient, prolific_project_id: str, qualification_name: str, ) -> Tuple[bool, Optional[ParticipantGroup]]: @@ -198,7 +243,9 @@ def _find_qualification( project_id=prolific_project_id, ) except (ProlificException, ValidationError): - logger.exception(f'Could not receive a qualifications for project "{prolific_project_id}"') + logger.exception( + f'Could not receive a qualifications for project "{prolific_project_id}"' + ) raise for qualification in qualifications: @@ -209,7 +256,7 @@ def _find_qualification( def find_or_create_qualification( - client: prolific_api, + client: ProlificClient, prolific_project_id: str, qualification_name: str, *args, @@ -217,7 +264,9 @@ def find_or_create_qualification( ) -> Optional[ParticipantGroup]: """Find or create a qualification (Prolific Participant Group) by name""" found_qualification, qualification = _find_qualification( - client, prolific_project_id, qualification_name, + client, + prolific_project_id, + qualification_name, ) if found_qualification: @@ -230,7 +279,7 @@ def find_or_create_qualification( ) except (ProlificException, ValidationError): logger.exception( - f'Could not create a qualification ' + f"Could not create a qualification " f'for project "{prolific_project_id}" with name "{qualification_name}"' ) raise @@ -238,23 +287,23 @@ def find_or_create_qualification( return qualification -def _ec2_external_url(task_run_config: 'DictConfig') -> str: +def _ec2_external_url(task_run_config: "DictConfig") -> str: c = constants url = ec2_architect.get_full_domain(args=task_run_config) url_with_args = ( - f'{url}?' - f'{c.STUDY_URL_PARTICIPANT_ID_PARAM}={c.STUDY_URL_PARTICIPANT_ID_PARAM_PROLIFIC_VAR}' - f'&{c.STUDY_URL_STUDY_ID_PARAM}={c.STUDY_URL_STUDY_ID_PARAM_PROLIFIC_VAR}' - f'&{c.STUDY_URL_SUBMISSION_ID_PARAM}={c.STUDY_URL_SUBMISSION_ID_PARAM_PROLIFIC_VAR}' + f"{url}?" + f"{c.STUDY_URL_PARTICIPANT_ID_PARAM}={c.STUDY_URL_PARTICIPANT_ID_PARAM_PROLIFIC_VAR}" + f"&{c.STUDY_URL_STUDY_ID_PARAM}={c.STUDY_URL_STUDY_ID_PARAM_PROLIFIC_VAR}" + f"&{c.STUDY_URL_SUBMISSION_ID_PARAM}={c.STUDY_URL_SUBMISSION_ID_PARAM_PROLIFIC_VAR}" ) return url_with_args -def _is_ec2_architect(task_run_config: 'DictConfig') -> bool: +def _is_ec2_architect(task_run_config: "DictConfig") -> bool: return task_run_config.architect._architect_type == ec2_architect.ARCHITECT_TYPE -def _get_external_study_url(task_run_config: 'DictConfig') -> str: +def _get_external_study_url(task_run_config: "DictConfig") -> str: if _is_ec2_architect(task_run_config): external_study_url = _ec2_external_url(task_run_config) else: @@ -263,18 +312,26 @@ def _get_external_study_url(task_run_config: 'DictConfig') -> str: def create_study( - client: prolific_api, task_run_config: 'DictConfig', prolific_project_id: str, *args, **kwargs, + client: ProlificClient, + task_run_config: "DictConfig", + prolific_project_id: str, + *args, + **kwargs, ) -> Study: """Create a task (Prolific Study)""" def compose_completion_codes(code_suffix: str) -> List[dict]: - return [dict( - code=f'{constants.StudyCodeType.COMPLETED}_{code_suffix}', - code_type=constants.StudyCodeType.COMPLETED, - actions=[dict( - action=constants.StudyAction.MANUALLY_REVIEW, - )], - )] + return [ + dict( + code=f"{constants.StudyCodeType.COMPLETED}_{code_suffix}", + code_type=constants.StudyCodeType.COMPLETED, + actions=[ + dict( + action=constants.StudyAction.MANUALLY_REVIEW, + ) + ], + ) + ] # Task info name = task_run_config.task.task_title @@ -295,7 +352,9 @@ def compose_completion_codes(code_suffix: str) -> List[dict]: # Initially provide a random completion code during study completion_codes_random = compose_completion_codes(uuid.uuid4().hex[:5]) - logger.debug(f'Initial completion codes for creating Study: {completion_codes_random}') + logger.debug( + f"Initial completion codes for creating Study: {completion_codes_random}" + ) try: # TODO (#1008): Make sure that all parameters are correct @@ -321,12 +380,14 @@ def compose_completion_codes(code_suffix: str) -> List[dict]: # This code will be used to redirect worker to Prolific's "Submission Completed" page # (see `mephisto.abstractions.providers.prolific.wrap_crowd_source.handleSubmitToProvider`) completion_codes_with_study_id = compose_completion_codes(study.id) - logger.debug(f'Final completion codes for updating Study: {completion_codes_with_study_id}') + logger.debug( + f"Final completion codes for updating Study: {completion_codes_with_study_id}" + ) study: Study = client.Studies.update( id=study.id, completion_codes=completion_codes_with_study_id, ) - logger.debug(f'Study was updated successfully! {study.completion_codes=}') + logger.debug(f"Study was updated successfully! {study.completion_codes=}") except (ProlificException, ValidationError): logger.exception( f'Could not create a Study with name "{name}" and instructions "{description}"' @@ -336,7 +397,7 @@ def compose_completion_codes(code_suffix: str) -> List[dict]: return study -def get_study(client: prolific_api, study_id: str) -> Study: +def get_study(client: ProlificClient, study_id: str) -> Study: try: study: Study = client.Studies.retrieve(id=study_id) except (ProlificException, ValidationError): @@ -345,7 +406,7 @@ def get_study(client: prolific_api, study_id: str) -> Study: return study -def publish_study(client: prolific_api, study_id: str) -> str: +def publish_study(client: ProlificClient, study_id: str) -> str: try: client.Studies.publish(id=study_id) except (ProlificException, ValidationError): @@ -354,7 +415,7 @@ def publish_study(client: prolific_api, study_id: str) -> str: return study_id -def expire_study(client: prolific_api, study_id: str) -> Study: +def expire_study(client: ProlificClient, study_id: str) -> Study: """ Prolific Studies don't have EXPIRED status, so we mark it as COMPLETED and add `_EXPIRED` to the end of `Study.internal_name` field. @@ -364,7 +425,7 @@ def expire_study(client: prolific_api, study_id: str) -> Study: study: Study = get_study(client, study_id) client.Studies.update( id=study_id, - internal_name=f'{study.internal_name}_{constants.StudyStatus._EXPIRED}' + internal_name=f"{study.internal_name}_{constants.StudyStatus._EXPIRED}", ) study: Study = client.Studies.stop(id=study_id) logger.debug(f'Study "{study_id}" was expired successfully!') @@ -381,19 +442,24 @@ def is_study_expired(study: Study) -> bool: because Prolific Study object doesn't have "expired" status """ Status = constants.StudyStatus - return ( - study.status in [Status.COMPLETED, Status.AWAITING_REVIEW] and - study.internal_name.endswith(Status._EXPIRED) - ) + return study.status in [ + Status.COMPLETED, + Status.AWAITING_REVIEW, + ] and study.internal_name.endswith(Status._EXPIRED) def give_worker_qualification( - client: prolific_api, worker_id: str, qualification_id: str, *args, **kwargs, + client: ProlificClient, + worker_id: str, + qualification_id: str, + *args, + **kwargs, ) -> None: """Give a qualification to the given worker (add a worker to a Participant Group)""" try: client.ParticipantGroups.add_participants_to_group( - id=qualification_id, participant_ids=[worker_id], + id=qualification_id, + participant_ids=[worker_id], ) except (ProlificException, ValidationError): logger.exception( @@ -403,12 +469,17 @@ def give_worker_qualification( def remove_worker_qualification( - client: prolific_api, worker_id: str, qualification_id: str, *args, **kwargs, + client: ProlificClient, + worker_id: str, + qualification_id: str, + *args, + **kwargs, ) -> None: """Remove a qualification for the given worker (remove a worker from a Participant Group)""" try: client.ParticipantGroups.remove_participants_from_group( - id=qualification_id, participant_ids=[worker_id], + id=qualification_id, + participant_ids=[worker_id], ) except (ProlificException, ValidationError): logger.exception( @@ -418,8 +489,8 @@ def remove_worker_qualification( def pay_bonus( - client: prolific_api, - task_run_config: 'DictConfig', + client: ProlificClient, + task_run_config: "DictConfig", worker_id: str, bonus_amount: int, # in cents study_id: str, @@ -430,14 +501,18 @@ def pay_bonus( Handles paying bonus to a worker. Returns True on success and False on failure (e.g. insufficient funds) """ - if not check_balance(workspace_name=task_run_config.provider.prolific_workspace_name): + if not check_balance( + workspace_name=task_run_config.provider.prolific_workspace_name + ): # Just in case if Prolific adds showing an available balance for an account - logger.debug('Cannot pay bonus. Reason: Insufficient funds in your Prolific account.') + logger.debug( + "Cannot pay bonus. Reason: Insufficient funds in your Prolific account." + ) return False # Unlike all other Prolific endpoints working with cents, this one requires dollars bonus_amount_in_dollars = bonus_amount / 100 - csv_bonuses = f'{worker_id},{bonus_amount_in_dollars}' + csv_bonuses = f"{worker_id},{bonus_amount_in_dollars}" try: bonus_obj: BonusPayments = client.Bonuses.set_up(study_id, csv_bonuses) @@ -456,22 +531,32 @@ def pay_bonus( def _get_block_list_qualification( - client: prolific_api, task_run_config: 'DictConfig', + client: ProlificClient, + task_run_config: "DictConfig", ) -> ParticipantGroup: workspace = find_or_create_prolific_workspace( - client, title=task_run_config.provider.prolific_workspace_name, + client, + title=task_run_config.provider.prolific_workspace_name, ) project = find_or_create_prolific_project( - client, workspace.id, title=task_run_config.provider.prolific_project_name, + client, + workspace.id, + title=task_run_config.provider.prolific_project_name, ) block_list_qualification = find_or_create_qualification( - client, project.id, task_run_config.provider.prolific_block_list_group_name, + client, + project.id, + task_run_config.provider.prolific_block_list_group_name, ) return block_list_qualification def block_worker( - client: prolific_api, task_run_config: 'DictConfig', worker_id: str, *args, **kwargs, + client: ProlificClient, + task_run_config: "DictConfig", + worker_id: str, + *args, + **kwargs, ) -> None: """Block a worker by id using the Prolific client, passes reason along""" block_list_qualification = _get_block_list_qualification(client, task_run_config) @@ -479,30 +564,43 @@ def block_worker( def unblock_worker( - client: prolific_api, task_run_config: 'DictConfig', worker_id: str, *args, **kwargs, + client: ProlificClient, + task_run_config: "DictConfig", + worker_id: str, + *args, + **kwargs, ) -> None: """Remove a block on the given worker""" block_list_qualification = _get_block_list_qualification(client, task_run_config) remove_worker_qualification(client, worker_id, block_list_qualification.id) -def is_worker_blocked(client: prolific_api, task_run_config: 'DictConfig', worker_id: str) -> bool: +def is_worker_blocked( + client: ProlificClient, task_run_config: "DictConfig", worker_id: str +) -> bool: """Determine if the given worker is blocked by this client""" workspace = find_or_create_prolific_workspace( - client, title=task_run_config.provider.prolific_workspace_name, + client, + title=task_run_config.provider.prolific_workspace_name, ) project = find_or_create_prolific_project( - client, workspace.id, title=task_run_config.provider.prolific_project_name, + client, + workspace.id, + title=task_run_config.provider.prolific_project_name, ) _, block_list_qualification = _find_qualification( - client, project.id, task_run_config.provider.prolific_block_list_group_name, + client, + project.id, + task_run_config.provider.prolific_block_list_group_name, ) if not block_list_qualification: return False try: - participants: List[Participant] = client.ParticipantGroups.list_participants_for_group( + participants: List[ + Participant + ] = client.ParticipantGroups.list_participants_for_group( block_list_qualification.id, ) except (ProlificException, ValidationError): @@ -519,20 +617,25 @@ def is_worker_blocked(client: prolific_api, task_run_config: 'DictConfig', worke def calculate_pay_amount( - client: prolific_api, task_amount: Union[int, float], total_available_places: int, + client: ProlificClient, + task_amount: Union[int, float], + total_available_places: int, ) -> Union[int, float]: try: total_cost: Union[int, float] = client.Studies.calculate_cost( - reward=task_amount, total_available_places=total_available_places, + reward=task_amount, + total_available_places=total_available_places, ) except (ProlificException, ValidationError): - logger.exception('Could not calculate total cost for a study') + logger.exception("Could not calculate total cost for a study") raise return total_cost def _find_submission( - client: prolific_api, study_id: str, worker_id: str, + client: ProlificClient, + study_id: str, + worker_id: str, ) -> Optional[ListSubmission]: """Find a Submission by Study and Worker""" try: @@ -548,11 +651,15 @@ def _find_submission( return None -def approve_work(client: prolific_api, study_id: str, worker_id: str) -> Union[Submission, None]: +def approve_work( + client: ProlificClient, study_id: str, worker_id: str +) -> Union[Submission, None]: submission: ListSubmission = _find_submission(client, study_id, worker_id) if not submission: - logger.warning(f'No submission found for study "{study_id}" and participant "{worker_id}"') + logger.warning( + f'No submission found for study "{study_id}" and participant "{worker_id}"' + ) return None # TODO (#1008): Maybe we need to expand handling submission statuses @@ -573,11 +680,15 @@ def approve_work(client: prolific_api, study_id: str, worker_id: str) -> Union[S return None -def reject_work(client: prolific_api, study_id: str, worker_id: str) -> Union[Submission, None]: +def reject_work( + client: ProlificClient, study_id: str, worker_id: str +) -> Union[Submission, None]: submission: ListSubmission = _find_submission(client, study_id, worker_id) if not submission: - logger.warning(f'No submission found for study "{study_id}" and participant "{worker_id}"') + logger.warning( + f'No submission found for study "{study_id}" and participant "{worker_id}"' + ) return None # TODO (#1008): Maybe we need to expand handling submission statuses