Spaces:
Paused
Paused
| """ | |
| Utility functions for handling Prolific APIs | |
| This module provides wrapper classes for interacting with the Prolific API | |
| (https://api.prolific.com/api/v1/) to manage studies and submissions. | |
| The Prolific API uses token-based authentication and returns JSON responses. | |
| All endpoints require the Authorization header with format: 'Token {your_token}' | |
| Key API Endpoints Used: | |
| - GET /studies/ - List all studies | |
| - GET /studies/{id}/ - Get study details | |
| - GET /submissions/ - List all submissions | |
| - GET /submissions?study={id} - Get submissions for a study | |
| - GET /submissions/{id}/ - Get submission details | |
| - GET /studies/{id}/submissions/ - Get recent submissions for a study | |
| - POST /studies/{id}/transition/ - Change study status (START/PAUSE) | |
| For more information, see: https://docs.prolific.com/reference/ | |
| """ | |
| import os.path | |
| import pandas as pd | |
| import requests | |
| from collections import OrderedDict, defaultdict | |
| import time | |
| import json | |
| import logging | |
| # The base wrapper of prolific apis | |
| class ProlificBase(object): | |
| """ | |
| Base class for Prolific API operations. | |
| Provides low-level API access to Prolific's REST endpoints. | |
| All methods use token-based authentication and handle HTTP responses. | |
| Attributes: | |
| headers (dict): HTTP headers including Authorization token | |
| """ | |
| def __init__(self, token): | |
| """ | |
| Initialize the API client with authentication token. | |
| Args: | |
| token (str): Prolific API token for authentication | |
| """ | |
| self.headers = { | |
| 'Authorization': f'Token {token}', | |
| } | |
| def list_all_studies(self): | |
| """ | |
| Retrieve all studies from Prolific. | |
| Makes a GET request to /api/v1/studies/ to fetch all studies | |
| associated with the authenticated account. | |
| Returns: | |
| pandas.DataFrame: DataFrame containing study information with columns: | |
| - id: Study identifier | |
| - name: Study name | |
| - study_type: Type of study | |
| - internal_name: Internal reference name | |
| - status: Current study status | |
| None: If the request fails | |
| """ | |
| url = 'https://api.prolific.com/api/v1/studies/' | |
| response = requests.get(url, headers=self.headers) | |
| if response.status_code == 200: | |
| data = response.json() # If the response contains JSON data | |
| studies = pd.DataFrame.from_records(data['results']) | |
| print('You currently have %s studies'%len(data['results'])) | |
| print(studies[['id','name','study_type','internal_name','status']].to_records()) | |
| return studies | |
| else: | |
| print(f"Error: {response.status_code} - {response.text}") | |
| return None | |
| def get_study_by_id(self, study_id): | |
| """ | |
| Retrieve detailed information about a specific study. | |
| Makes a GET request to /api/v1/studies/{study_id}/ to fetch | |
| complete study details including configuration and status. | |
| Args: | |
| study_id (str, optional): Prolific study ID. If None, uses self.study_id | |
| Returns: | |
| dict: Complete study information including: | |
| - id, name, internal_name | |
| - reward, average_reward_per_hour | |
| - external_study_url, status | |
| - total_available_places, places_taken | |
| - and other study configuration fields | |
| None: If the request fails | |
| """ | |
| if study_id == None: | |
| study_id = self.study_id | |
| url = f'https://api.prolific.com/api/v1/studies/{study_id}/' | |
| response = requests.get(url, headers=self.headers) | |
| if response.status_code == 200: | |
| data = response.json() # If the response contains JSON data | |
| return data | |
| else: | |
| print(f"Error: {response.status_code} - {response.text}") | |
| return None | |
| def get_submissions(self): | |
| """ | |
| Retrieve all submissions across all studies. | |
| Makes a GET request to /api/v1/submissions/ to fetch all submissions. | |
| Note: This can be slow for accounts with many submissions. | |
| Returns: | |
| list: List of submission dictionaries containing: | |
| - id: Submission identifier | |
| - participant_id: Participant's Prolific ID | |
| - study_id: Associated study ID | |
| - status: Current submission status | |
| - and other submission details | |
| None: If the request fails | |
| """ | |
| url = 'https://api.prolific.com/api/v1/submissions/' | |
| response = requests.get(url, headers=self.headers) | |
| if response.status_code == 200: | |
| data = response.json() # If the response contains JSON data | |
| print('You currently have %s submissions'%len(data['results'])) | |
| return data['results'] | |
| else: | |
| print(f"Error: {response.status_code} - {response.text}") | |
| return None | |
| def get_submissions_from_study(self, study_id = None): | |
| """ | |
| Retrieve all submissions for a specific study. | |
| Makes a GET request to /api/v1/submissions?study={study_id} to fetch | |
| submissions filtered by study ID. | |
| Args: | |
| study_id (str, optional): Prolific study ID. If None, uses self.study_id | |
| Returns: | |
| list: List of submission dictionaries for the specified study | |
| None: If the request fails | |
| """ | |
| if study_id == None: | |
| study_id = self.study_id | |
| api_endpoint = 'https://api.prolific.com/api/v1/submissions?study={}' | |
| url = api_endpoint.format(study_id) | |
| response = requests.get(url, headers=self.headers) | |
| if response.status_code == 200: | |
| data = response.json()['results'] | |
| print('Successfully fetched %s submissions from study %s' % (len(data), study_id)) | |
| return data | |
| else: | |
| print(f"Error: {response.status_code} - {response.text}") | |
| return None | |
| def get_submission_from_id(self, submission_id): | |
| """ | |
| Retrieve detailed information about a specific submission. | |
| Makes a GET request to /api/v1/submissions/{submission_id}/ to fetch | |
| complete submission details including participant info and status. | |
| Args: | |
| submission_id (str): Prolific submission ID | |
| Returns: | |
| dict: Complete submission information including: | |
| - id, participant_id, study_id | |
| - status: Current submission status | |
| - started_at, completed_at timestamps | |
| - and other submission details | |
| None: If the request fails | |
| """ | |
| url = f'https://api.prolific.com/api/v1/submissions/{submission_id}/' | |
| response = requests.get(url, headers=self.headers) | |
| if response.status_code == 200: | |
| data = response.json() # If the response contains JSON data | |
| return data | |
| else: | |
| print(f"Error: {response.status_code} - {response.text}") | |
| return None | |
| def get_recent_study_submissions(self, study_id): | |
| """ | |
| Retrieve recent submissions for a specific study. | |
| Makes a GET request to /api/v1/studies/{study_id}/submissions/ to fetch | |
| recent submissions. This endpoint may return a subset of submissions | |
| compared to get_submissions_from_study(). | |
| Args: | |
| study_id (str, optional): Prolific study ID. If None, uses self.study_id | |
| Returns: | |
| list: List of recent submission dictionaries for the specified study | |
| None: If the request fails | |
| """ | |
| if study_id == None: | |
| study_id = self.study_id | |
| url = f'https://api.prolific.com/api/v1/studies/{study_id}/submissions/' | |
| response = requests.get(url, headers=self.headers) | |
| if response.status_code == 200: | |
| data = response.json() # If the response contains JSON data | |
| print('You currently have %s submissions' % len(data['results'])) | |
| return (data['results']) | |
| else: | |
| print(f"Error: {response.status_code} - {response.text}") | |
| return None | |
| def get_study_status(self, study_id = None): | |
| """ | |
| Get the current status of a study. | |
| Retrieves study information and extracts the status field. | |
| Args: | |
| study_id (str, optional): Prolific study ID. If None, uses self.study_id | |
| Returns: | |
| str: Study status (e.g., 'ACTIVE', 'PAUSED', 'COMPLETED') | |
| None: If the request fails | |
| """ | |
| if study_id == None: | |
| study_id = self.study_id | |
| data = self.get_study_by_id(study_id) | |
| if data: | |
| return data['status'] | |
| else: | |
| return None | |
| def pause_study(self, study_id = None): | |
| """ | |
| Pause a study to stop new participants from joining. | |
| Makes a POST request to /api/v1/studies/{study_id}/transition/ with | |
| action "PAUSE" to change the study status to paused. | |
| Args: | |
| study_id (str, optional): Prolific study ID. If None, uses self.study_id | |
| Returns: | |
| dict: Response from the transition API call | |
| """ | |
| if study_id == None: | |
| study_id = self.study_id | |
| api_endpoint = 'https://api.prolific.com/api/v1/studies/{}/transition/' | |
| url = api_endpoint.format(study_id) | |
| data = { | |
| "action": "PAUSE" | |
| } | |
| response = requests.post(url, headers=self.headers, json=data) | |
| data = response.json() | |
| print(study_id, self.get_study_status(study_id)) | |
| return data | |
| def start_study(self, study_id = None): | |
| """ | |
| Start a study to allow new participants to join. | |
| Makes a POST request to /api/v1/studies/{study_id}/transition/ with | |
| action "START" to change the study status to active. | |
| Args: | |
| study_id (str, optional): Prolific study ID. If None, uses self.study_id | |
| Returns: | |
| dict: Response from the transition API call | |
| """ | |
| if study_id == None: | |
| study_id = self.study_id | |
| api_endpoint = 'https://api.prolific.com/api/v1/studies/{}/transition/' | |
| url = api_endpoint.format(study_id) | |
| data = { | |
| "action": "START" | |
| } | |
| response = requests.post(url, headers=self.headers, json=data) | |
| data = response.json() | |
| print(study_id, self.get_study_status(study_id)) | |
| return data | |
| # The class to manage the status of a prolific study | |
| class ProlificStudy(ProlificBase): | |
| """ | |
| High-level study management class for Prolific studies. | |
| Extends ProlificBase to provide study-specific functionality including: | |
| - Submission tracking and status management | |
| - Workload monitoring and automatic study control | |
| - Local state persistence | |
| Attributes: | |
| study_id (str): Prolific study identifier | |
| study_info (dict): Cached study information | |
| submission_info_path (str): Path to local submission data file | |
| sessions (OrderedDict): Mapping of submission IDs to submission data | |
| user_status_dict (defaultdict): Mapping of status to participant IDs | |
| max_concurrent_sessions (int): Maximum allowed concurrent participants | |
| checker_period (int): Seconds between workload checks | |
| workload_checker_on (bool): Whether workload checker is running | |
| """ | |
| def __init__(self, token, study_id, saving_dir, max_concurrent_sessions = 30, workload_checker_period = 60): | |
| """ | |
| Initialize study management with configuration. | |
| Args: | |
| token (str): Prolific API token | |
| study_id (str): Prolific study identifier | |
| saving_dir (str): Directory to save submission data | |
| max_concurrent_sessions (int): Maximum concurrent participants (default: 30) | |
| workload_checker_period (int): Seconds between workload checks (default: 60) | |
| """ | |
| ProlificBase.__init__(self, token) | |
| self.study_id = study_id | |
| self.study_info = self.get_study_by_id(study_id) | |
| self.submission_info_path = os.path.join(saving_dir, 'submissions.json') | |
| self.sessions = OrderedDict() | |
| self.user2session = {} | |
| #self.user_status_dict = {'RESERVED':set(), 'AWAITING REVIEW':set(), 'RETURNED':set(), 'TIMED-OUT':set(), 'ACTIVE': set(), 'APPROVED':set(), 'REJECTED':set()} | |
| self.study_status = None | |
| self.status_path = None | |
| self.max_concurrent_sessions = max_concurrent_sessions # How many users can work on the study at the same time | |
| self.checker_period = workload_checker_period | |
| self.workload_checker_remaining_time = workload_checker_period | |
| self.workload_checker_on = False | |
| def get_basic_study_info(self): | |
| """ | |
| Extract basic study information for display or logging. | |
| Returns: | |
| dict: Basic study information including: | |
| - id, name, internal_name | |
| - reward, average_reward_per_hour | |
| - external_study_url, status | |
| - total_available_places, places_taken | |
| """ | |
| keys = ['id', 'name', 'internal_name', | |
| 'reward', 'average_reward_per_hour', 'external_study_url', 'status', 'total_available_places', 'places_taken'] | |
| return {key:self.study_info[key] for key in keys} | |
| def update_submission_status(self): | |
| """ | |
| Refresh local submission data from Prolific API. | |
| Fetches current submission data from the API and updates local state: | |
| - Saves submission data to local JSON file | |
| - Updates sessions mapping | |
| - Rebuilds user status dictionary grouped by submission status | |
| The user_status_dict maps submission statuses to sets of participant IDs: | |
| - 'ACTIVE': Currently working participants | |
| - 'AWAITING REVIEW': Completed submissions pending review | |
| - 'APPROVED': Approved submissions | |
| - 'REJECTED': Rejected submissions | |
| - 'RETURNED': Participants who returned the study | |
| - 'TIMED-OUT': Participants who timed out | |
| """ | |
| submission_data = self.get_submissions_from_study() | |
| with open(self.submission_info_path, "wt") as f: | |
| for v in submission_data: | |
| f.writelines(json.dumps(v) + "\n") | |
| self.user_status_dict = defaultdict(set) | |
| for v in submission_data: | |
| self.sessions[v['id']] = v | |
| #self.user2session[v['participant_id']] = v['id'] | |
| self.user_status_dict[v['status']].add(v['participant_id']) | |
| self.reclaim_dropped_user_assignments() | |
| def get_dropped_users(self): | |
| """ | |
| Get list of participants who are no longer active. | |
| Returns: | |
| list: Participant IDs who have returned, timed out, or been rejected | |
| """ | |
| return list(self.user_status_dict['RETURNED'] | self.user_status_dict['TIMED-OUT'] | self.user_status_dict['REJECTED']) | |
| def get_dropped_users_by_status(self): | |
| """ | |
| Get dropped participant IDs grouped by Prolific submission status. | |
| Returns: | |
| dict: Mapping from RETURNED, TIMED-OUT, and REJECTED to participant IDs. | |
| """ | |
| return { | |
| 'RETURNED': list(self.user_status_dict['RETURNED']), | |
| 'TIMED-OUT': list(self.user_status_dict['TIMED-OUT']), | |
| 'REJECTED': list(self.user_status_dict['REJECTED']), | |
| } | |
| def reclaim_dropped_user_assignments(self): | |
| """ | |
| Release unannotated Potato assignments for dropped Prolific workers. | |
| Prolific reports dropped workers as RETURNED, TIMED-OUT, or REJECTED. | |
| Their participant IDs are also the Potato usernames for url-direct | |
| Prolific login, so the item state manager can safely reclaim any | |
| assigned-but-unannotated instances. | |
| """ | |
| dropped_by_status = self.get_dropped_users_by_status() | |
| if not any(dropped_by_status.values()): | |
| return {} | |
| status_to_reason = { | |
| 'RETURNED': 'prolific_returned', | |
| 'TIMED-OUT': 'prolific_timed_out', | |
| 'REJECTED': 'prolific_rejected', | |
| } | |
| try: | |
| from potato.item_state_management import get_item_state_manager | |
| manager = get_item_state_manager() | |
| reclaimed = {} | |
| for status, user_ids in dropped_by_status.items(): | |
| if not user_ids: | |
| continue | |
| reclaimed.update( | |
| manager.reclaim_unannotated_assignments_for_users( | |
| user_ids, | |
| reason=status_to_reason[status], | |
| ) | |
| ) | |
| return reclaimed | |
| except Exception as e: | |
| logging.getLogger(__name__).warning( | |
| "Could not reclaim assignments for dropped Prolific users: %s", | |
| e, | |
| ) | |
| return {} | |
| def get_concurrent_sessions_count(self): | |
| """ | |
| Get the number of currently active participants. | |
| Returns: | |
| int: Number of participants with 'ACTIVE' status | |
| """ | |
| return len(self.user_status_dict['ACTIVE']) | |
| def workload_checker(self): | |
| """ | |
| Monitor study workload and automatically manage study status. | |
| Periodically checks the number of active participants and automatically: | |
| - Pauses the study if too many participants are active | |
| - Resumes the study when active participants drop below threshold | |
| The threshold is 20% of max_concurrent_sessions. The checker runs | |
| continuously with the specified checker_period interval. | |
| This method runs in an infinite loop and should be called in a separate | |
| thread to avoid blocking the main application. | |
| """ | |
| if self.workload_checker_on: | |
| print('Workload checker already in process, time remaining: %s seconds' % self.workload_checker_remaining_time) | |
| return None | |
| else: | |
| print('Workload checker started, checking every %s seconds'%self.checker_period) | |
| while True: | |
| self.workload_checker_remaining_time = self.checker_period | |
| self.workload_checker_on = True | |
| print(f"\rChecking workload in: {self.checker_period} seconds") | |
| #time.sleep(self.checker_period) | |
| for i in range(self.checker_period, 0, -1): | |
| #print(f"\rChecking workload in: {i}s", end='', flush=True) | |
| self.workload_checker_remaining_time -= 1 | |
| time.sleep(1) | |
| self.update_submission_status() | |
| if self.get_concurrent_sessions_count() < 0.2 * self.max_concurrent_sessions: | |
| self.workload_checker_on = False | |
| print('current workload: ', self.get_concurrent_sessions_count(), ', resuming study %s'%self.study_id) | |
| self.start_study() | |
| return None | |
| else: | |
| print('current workload: ', self.get_concurrent_sessions_count(), ', starting another workload checker') | |
| def update_session_status(self, sess_id): | |
| """ | |
| Update the status of a specific submission. | |
| Fetches current status from Prolific API and updates local session data. | |
| Args: | |
| sess_id (str): Submission ID to update | |
| """ | |
| status = self.get_submission_from_id(sess_id)['status'] | |
| self.sessions[sess_id]['status'] = status | |
| def add_new_user(self, user): | |
| """ | |
| Add a new participant to the local session tracking. | |
| Fetches current submission status and adds to local state. | |
| Args: | |
| user (dict): User data containing 'SESSION_ID' and 'PROLIFIC_PID' | |
| """ | |
| status = self.get_submission_from_id(user['SESSION_ID'])['status'] | |
| self.sessions[user['SESSION_ID']] = {'username':user['PROLIFIC_PID'], 'status':status} | |
| self.session_status_dict[status].append(user['SESSION_ID']) | |