diff --git a/cmd/study/create.go b/cmd/study/create.go index 4da0df7..082d25d 100644 --- a/cmd/study/create.go +++ b/cmd/study/create.go @@ -41,30 +41,33 @@ If you are using the CLI in other tooling, you may want to silence the returned output of the study creation, so you can use the "-s" flag. $ prolific study create -t /path/to/study.json -p -s -An example of a JSON study file, with an ethnicity screener +An example of a JSON study file, with completion codes and filters { - "name": "Study with a ethnicity screener", - "internal_name": "Study with a ethnicity screener", - "description": "This study will be published to the participants with the selected ethnicity", + "name": "Study with completion codes", + "internal_name": "Study with completion codes", + "description": "This study uses completion codes and a handedness filter set", "external_study_url": "https://google.com", - "prolific_id_option": "question", - "completion_code": "COMPLE01", - "completion_option": "code", + "prolific_id_option": "url_parameters", + "completion_codes": [ + { + "code": "C1234567", + "code_type": "COMPLETED", + "actions": [{"action": "AUTOMATICALLY_APPROVE"}] + }, + { + "code": "C7654321", + "code_type": "FAILED_ATTENTION_CHECK", + "actions": [{"action": "MANUALLY_REVIEW"}] + } + ], "total_available_places": 10, "estimated_completion_time": 10, "maximum_allowed_time": 10, "reward": 400, "device_compatibility": ["desktop", "tablet", "mobile"], "peripheral_requirements": ["audio", "camera", "download", "microphone"], - "eligibility_requirements": [ - { - "attributes": [{ "index": 3, "value": true }], - "query": { "id": "5950c8413e9d730001924f2a" }, - "_cls": "web.eligibility.models.SelectAnswerEligibilityRequirement" - } - ], - "credential_pool_id": "64a1b2c3d4e5f6a7b8c9d0e1_12345678-1234-11e0-8000-0a1b2c3d4e5f" + "filter_set_id": "handedness" } An example of a YAML study file @@ -75,10 +78,17 @@ internal_name: Standard sample description: This is my first standard sample study on the Prolific system. external_study_url: https://eggs-experriment.com # Enum: "question", "url_parameters" (Recommended), "not_required" -prolific_id_option: question -completion_code: COMPLE01 -# Enum: "url", "code" -completion_option: code +prolific_id_option: url_parameters +# New completion_codes array format (recommended) +completion_codes: + - code: C1234567 + code_type: COMPLETED + actions: + - action: AUTOMATICALLY_APPROVE + - code: C7654321 + code_type: FAILED_ATTENTION_CHECK + actions: + - action: MANUALLY_REVIEW total_available_places: 10 # In minutes estimated_completion_time: 10 diff --git a/cmd/study/create_test.go b/cmd/study/create_test.go index 64275c9..12308c8 100644 --- a/cmd/study/create_test.go +++ b/cmd/study/create_test.go @@ -16,24 +16,36 @@ import ( ) var studyTemplate = model.CreateStudy{ - Name: "My first standard sample", - InternalName: "Standard sample", - Description: "This is my first standard sample study on the Prolific system.", - ExternalStudyURL: "https://eggs-experriment.com?participant={{%PROLIFIC_PID%}}", - ProlificIDOption: "url_parameters", - CompletionCode: "COMPLE01", + Name: "My first standard sample", + InternalName: "Standard sample", + Description: "This is my first standard sample study on the Prolific system.", + ExternalStudyURL: "https://eggs-experriment.com?participant={{%PROLIFIC_PID%}}", + ProlificIDOption: "url_parameters", + CompletionCodes: []model.CompletionCode{ + { + Code: "COMPLE01", + CodeType: "COMPLETED", + Actions: []model.CompletionCodeAction{ + { + Action: "AUTOMATICALLY_APPROVE", + }, + }, + }, + }, TotalAvailablePlaces: 10, EstimatedCompletionTime: 10, - MaximumAllowedTime: 10, + MaximumAllowedTime: 100, Reward: 400, DeviceCompatibility: []string{"desktop", "tablet", "mobile"}, PeripheralRequirements: []string{"audio", "camera", "download", "microphone"}, SubmissionsConfig: struct { - MaxSubmissionsPerParticipant int `json:"max_submissions_per_participant,omitempty" mapstructure:"max_submissions_per_participant"` - MaxConcurrentSubmissions int `json:"max_concurrent_submissions,omitempty" mapstructure:"max_concurrent_submissions"` + MaxSubmissionsPerParticipant int `json:"max_submissions_per_participant,omitempty" mapstructure:"max_submissions_per_participant"` + MaxConcurrentSubmissions int `json:"max_concurrent_submissions,omitempty" mapstructure:"max_concurrent_submissions"` + AutoRejectionCategories []string `json:"auto_rejection_categories,omitempty" mapstructure:"auto_rejection_categories"` }{ MaxSubmissionsPerParticipant: -1, MaxConcurrentSubmissions: 0, + AutoRejectionCategories: nil, }, } @@ -44,7 +56,7 @@ var actualStudy = model.Study{ ExternalStudyURL: "https://eggs-experriment.com?participant={{%PROLIFIC_PID%}}", TotalAvailablePlaces: 10, EstimatedCompletionTime: 10, - MaximumAllowedTime: 10, + MaximumAllowedTime: 100, Reward: 400, DeviceCompatibility: []string{"desktop", "tablet", "mobile"}, } diff --git a/docs/examples/standard-sample.json b/docs/examples/standard-sample.json index f6e9506..ac12a20 100644 --- a/docs/examples/standard-sample.json +++ b/docs/examples/standard-sample.json @@ -4,10 +4,20 @@ "description": "This is my first standard sample study on the Prolific system.", "external_study_url": "https://eggs-experriment.com?participant={{%PROLIFIC_PID%}}", "prolific_id_option": "url_parameters", - "completion_code": "COMPLE01", + "completion_codes": [ + { + "code": "COMPLE01", + "code_type": "COMPLETED", + "actions": [ + { + "action": "AUTOMATICALLY_APPROVE" + } + ] + } + ], "total_available_places": 10, "estimated_completion_time": 10, - "maximum_allowed_time": 10, + "maximum_allowed_time": 100, "reward": 400, "device_compatibility": ["desktop", "tablet", "mobile"], "peripheral_requirements": ["audio", "camera", "download", "microphone"], diff --git a/docs/examples/standard-sample.yaml b/docs/examples/standard-sample.yaml index 194c93a..8e7a84b 100644 --- a/docs/examples/standard-sample.yaml +++ b/docs/examples/standard-sample.yaml @@ -3,10 +3,16 @@ internal_name: Standard sample description: This is my first standard sample study on the Prolific system. external_study_url: "https://eggs-experriment.com?participant={{%PROLIFIC_PID%}}" prolific_id_option: url_parameters -completion_code: COMPLE01 + +completion_codes: + - code: COMPLE01 + code_type: COMPLETED + actions: + - action: AUTOMATICALLY_APPROVE + total_available_places: 10 estimated_completion_time: 10 -maximum_allowed_time: 10 +maximum_allowed_time: 100 reward: 400 device_compatibility: - desktop diff --git a/model/study.go b/model/study.go index 95404bf..0c76230 100644 --- a/model/study.go +++ b/model/study.go @@ -111,6 +111,25 @@ type Study struct { CredentialPoolID string `json:"credential_pool_id"` } +// CompletionCode represents a completion code configuration for a study. +type CompletionCode struct { + Code string `json:"code" mapstructure:"code"` + CodeType string `json:"code_type" mapstructure:"code_type"` + Actions []CompletionCodeAction `json:"actions" mapstructure:"actions"` +} + +// CompletionCodeAction represents an action to take when a completion code is used. +type CompletionCodeAction struct { + Action string `json:"action" mapstructure:"action"` + ParticipantGroup string `json:"participant_group,omitempty" mapstructure:"participant_group,omitempty"` +} + +// AccessDetail represents a taskflow study URL allocation. +type AccessDetail struct { + ExternalURL string `json:"external_url" mapstructure:"external_url"` + TotalAllocation int `json:"total_allocation" mapstructure:"total_allocation"` +} + // CreateStudy is responsible for capturing what fields we need to send // to Prolific to create a study. The `mapstructure` is so we can take a viper // configuration file. @@ -121,10 +140,17 @@ type CreateStudy struct { ExternalStudyURL string `json:"external_study_url,omitempty" mapstructure:"external_study_url"` // Enum "question", "url_parameters" (Recommended), "not_required" ProlificIDOption string `json:"prolific_id_option" mapstructure:"prolific_id_option"` - CompletionCode string `json:"completion_code" mapstructure:"completion_code"` + + // New: Array of completion code configurations (replaces completion_code and completion_option) + CompletionCodes []CompletionCode `json:"completion_codes,omitempty" mapstructure:"completion_codes"` + + // DEPRECATED: Use CompletionCodes instead. Kept for backward compatibility. + CompletionCode string `json:"completion_code,omitempty" mapstructure:"completion_code"` + // DEPRECATED: Use CompletionCodes instead. Kept for backward compatibility. // Enum: "url", "code" - CompletionOption string `json:"completion_option,omitempty" mapstructure:"completion_option"` - TotalAvailablePlaces int `json:"total_available_places" mapstructure:"total_available_places"` + CompletionOption string `json:"completion_option,omitempty" mapstructure:"completion_option"` + + TotalAvailablePlaces int `json:"total_available_places" mapstructure:"total_available_places"` // Minutes EstimatedCompletionTime int `json:"estimated_completion_time" mapstructure:"estimated_completion_time"` MaximumAllowedTime int `json:"maximum_allowed_time,omitempty" mapstructure:"maximum_allowed_time"` @@ -135,18 +161,45 @@ type CreateStudy struct { PeripheralRequirements []string `json:"peripheral_requirements,omitempty" mapstructure:"peripheral_requirements"` // Study labels for categorization (e.g., "ai_annotation") StudyLabels []string `json:"study_labels,omitempty" mapstructure:"study_labels"` + + // New: Array of access details for taskflow studies with multiple URLs (replaces access_details_collection_id) + AccessDetails []AccessDetail `json:"access_details,omitempty" mapstructure:"access_details"` + + // DEPRECATED: Use AccessDetails instead. Kept for backward compatibility. // Access details collection ID: ID of the collection to attach to the study (for Taskflow studies) AccessDetailsCollectionID string `json:"access_details_collection_id,omitempty" mapstructure:"access_details_collection_id"` - // Data collection method: "AI_TASK_BUILDER", "DC_TOOL", or "HUMAN_SIGNAL" + + // Data collection method: "AI_TASK_BUILDER" DataCollectionMethod string `json:"data_collection_method,omitempty" mapstructure:"data_collection_method"` // Data collection ID: Project/collection/batch ID for data collection DataCollectionID string `json:"data_collection_id,omitempty" mapstructure:"data_collection_id"` // Data collection metadata: Configuration parameters (optional dict) - DataCollectionMetadata map[string]interface{} `json:"data_collection_metadata,omitempty" mapstructure:"data_collection_metadata"` - SubmissionsConfig struct { - MaxSubmissionsPerParticipant int `json:"max_submissions_per_participant,omitempty" mapstructure:"max_submissions_per_participant"` - MaxConcurrentSubmissions int `json:"max_concurrent_submissions,omitempty" mapstructure:"max_concurrent_submissions"` + DataCollectionMetadata map[string]any `json:"data_collection_metadata,omitempty" mapstructure:"data_collection_metadata"` + + // New: Predefined filter set configuration + FilterSetID string `json:"filter_set_id,omitempty" mapstructure:"filter_set_id"` + FilterSetVersion int `json:"filter_set_version,omitempty" mapstructure:"filter_set_version"` + + // New: Custom screening flag + IsCustomScreening bool `json:"is_custom_screening,omitempty" mapstructure:"is_custom_screening"` + + // New: Content warnings + ContentWarnings []string `json:"content_warnings,omitempty" mapstructure:"content_warnings"` + ContentWarningDetails string `json:"content_warning_details,omitempty" mapstructure:"content_warning_details"` + + // New: Custom metadata + Metadata map[string]any `json:"metadata,omitempty" mapstructure:"metadata"` + + // New: JWT security flag for external study URLs + IsExternalStudyURLSecure bool `json:"is_external_study_url_secure,omitempty" mapstructure:"is_external_study_url_secure"` + + SubmissionsConfig struct { + MaxSubmissionsPerParticipant int `json:"max_submissions_per_participant,omitempty" mapstructure:"max_submissions_per_participant"` + MaxConcurrentSubmissions int `json:"max_concurrent_submissions,omitempty" mapstructure:"max_concurrent_submissions"` + AutoRejectionCategories []string `json:"auto_rejection_categories,omitempty" mapstructure:"auto_rejection_categories"` } `json:"submissions_config,omitempty" mapstructure:"submissions_config"` + + // DEPRECATED: Use Filters or FilterSetID instead. Kept for backward compatibility. EligibilityRequirements []struct { Attributes []struct { ID string `json:"id" mapstructure:"id"` @@ -172,8 +225,9 @@ type UpdateStudy struct { // SubmissionsConfig represents configuration around submission gathering type SubmissionsConfig struct { - MaxSubmissionsPerParticipant int `json:"max_submissions_per_participant"` - MaxConcurrentSubmissions int `json:"max_concurrent_submissions"` + MaxSubmissionsPerParticipant int `json:"max_submissions_per_participant"` + MaxConcurrentSubmissions int `json:"max_concurrent_submissions"` + AutoRejectionCategories []string `json:"auto_rejection_categories,omitempty"` } // FilterValue will help the bubbletea views run diff --git a/model/study_test.go b/model/study_test.go index f45afca..c548fca 100644 --- a/model/study_test.go +++ b/model/study_test.go @@ -1,11 +1,14 @@ package model_test import ( + "encoding/json" "testing" "github.com/prolific-oss/cli/model" ) +const testCompletionCode = "C1234567" + func TestFilterValueReturnsName(t *testing.T) { name := "Patterns of migratory birds" study := model.Study{ @@ -72,3 +75,255 @@ func TestGetCurrencyCodeCanFigureOutWhichCurrencyToUse(t *testing.T) { }) } } + +func TestCompletionCodeUnmarshal(t *testing.T) { + jsonData := `{ + "code": "C1234567", + "code_type": "COMPLETED", + "actions": [ + { + "action": "AUTOMATICALLY_APPROVE" + } + ] + }` + + var cc model.CompletionCode + err := json.Unmarshal([]byte(jsonData), &cc) + if err != nil { + t.Fatalf("failed to unmarshal CompletionCode: %v", err) + } + + if cc.Code != testCompletionCode { + t.Errorf("expected code to be %s, got %s", testCompletionCode, cc.Code) + } + if cc.CodeType != "COMPLETED" { + t.Errorf("expected code_type to be COMPLETED, got %s", cc.CodeType) + } + if len(cc.Actions) != 1 { + t.Fatalf("expected 1 action, got %d", len(cc.Actions)) + } + if cc.Actions[0].Action != "AUTOMATICALLY_APPROVE" { + t.Errorf("expected action to be AUTOMATICALLY_APPROVE, got %s", cc.Actions[0].Action) + } +} + +func TestAccessDetailUnmarshal(t *testing.T) { + jsonData := `{ + "external_url": "https://example.com/task1", + "total_allocation": 100 + }` + + var ad model.AccessDetail + err := json.Unmarshal([]byte(jsonData), &ad) + if err != nil { + t.Fatalf("failed to unmarshal AccessDetail: %v", err) + } + + if ad.ExternalURL != "https://example.com/task1" { + t.Errorf("expected external_url to be https://example.com/task1, got %s", ad.ExternalURL) + } + if ad.TotalAllocation != 100 { + t.Errorf("expected total_allocation to be 100, got %d", ad.TotalAllocation) + } +} + +func TestCreateStudyWithCompletionCodes(t *testing.T) { + jsonData := `{ + "name": "Test Study", + "internal_name": "test-study", + "description": "A test study", + "prolific_id_option": "url_parameters", + "completion_codes": [ + { + "code": "C1234567", + "code_type": "COMPLETED", + "actions": [ + { + "action": "AUTOMATICALLY_APPROVE" + } + ] + }, + { + "code": "C7654321", + "code_type": "REJECTED", + "actions": [ + { + "action": "AUTOMATICALLY_REJECT" + } + ] + } + ], + "total_available_places": 100, + "estimated_completion_time": 10, + "reward": 1.5, + "device_compatibility": ["desktop"] + }` + + var study model.CreateStudy + err := json.Unmarshal([]byte(jsonData), &study) + if err != nil { + t.Fatalf("failed to unmarshal CreateStudy: %v", err) + } + + if len(study.CompletionCodes) != 2 { + t.Fatalf("expected 2 completion codes, got %d", len(study.CompletionCodes)) + } + if study.CompletionCodes[0].Code != testCompletionCode { + t.Errorf("expected first code to be %s, got %s", testCompletionCode, study.CompletionCodes[0].Code) + } + if study.CompletionCodes[1].CodeType != "REJECTED" { + t.Errorf("expected second code_type to be REJECTED, got %s", study.CompletionCodes[1].CodeType) + } +} + +func TestCreateStudyWithAccessDetails(t *testing.T) { + jsonData := `{ + "name": "Test Study", + "internal_name": "test-study", + "description": "A test study", + "prolific_id_option": "url_parameters", + "access_details": [ + { + "external_url": "https://example.com/task1", + "total_allocation": 50 + }, + { + "external_url": "https://example.com/task2", + "total_allocation": 50 + } + ], + "total_available_places": 100, + "estimated_completion_time": 10, + "reward": 1.5, + "device_compatibility": ["desktop"] + }` + + var study model.CreateStudy + err := json.Unmarshal([]byte(jsonData), &study) + if err != nil { + t.Fatalf("failed to unmarshal CreateStudy: %v", err) + } + + if len(study.AccessDetails) != 2 { + t.Fatalf("expected 2 access details, got %d", len(study.AccessDetails)) + } + if study.AccessDetails[0].ExternalURL != "https://example.com/task1" { + t.Errorf("expected first URL to be https://example.com/task1, got %s", study.AccessDetails[0].ExternalURL) + } + if study.AccessDetails[1].TotalAllocation != 50 { + t.Errorf("expected second allocation to be 50, got %d", study.AccessDetails[1].TotalAllocation) + } +} + +func TestCreateStudyBackwardCompatibilityWithCompletionCode(t *testing.T) { + jsonData := `{ + "name": "Test Study", + "internal_name": "test-study", + "description": "A test study", + "prolific_id_option": "url_parameters", + "completion_code": "C1234567", + "completion_option": "code", + "total_available_places": 100, + "estimated_completion_time": 10, + "reward": 1.5, + "device_compatibility": ["desktop"] + }` + + var study model.CreateStudy + err := json.Unmarshal([]byte(jsonData), &study) + if err != nil { + t.Fatalf("failed to unmarshal CreateStudy: %v", err) + } + + if study.CompletionCode != testCompletionCode { + t.Errorf("expected completion_code to be %s, got %s", testCompletionCode, study.CompletionCode) + } + if study.CompletionOption != "code" { + t.Errorf("expected completion_option to be code, got %s", study.CompletionOption) + } +} + +func TestSubmissionsConfigWithAutoRejectionCategories(t *testing.T) { + jsonData := `{ + "name": "Test Study", + "internal_name": "test-study", + "description": "A test study", + "prolific_id_option": "url_parameters", + "total_available_places": 100, + "estimated_completion_time": 10, + "reward": 1.5, + "device_compatibility": ["desktop"], + "submissions_config": { + "max_submissions_per_participant": 5, + "max_concurrent_submissions": 2, + "auto_rejection_categories": ["EXCEPTIONALLY_FAST"] + } + }` + + var study model.CreateStudy + err := json.Unmarshal([]byte(jsonData), &study) + if err != nil { + t.Fatalf("failed to unmarshal CreateStudy: %v", err) + } + + if study.SubmissionsConfig.MaxSubmissionsPerParticipant != 5 { + t.Errorf("expected max_submissions_per_participant to be 5, got %d", study.SubmissionsConfig.MaxSubmissionsPerParticipant) + } + if len(study.SubmissionsConfig.AutoRejectionCategories) != 1 { + t.Fatalf("expected 2 auto_rejection_categories, got %d", len(study.SubmissionsConfig.AutoRejectionCategories)) + } + if study.SubmissionsConfig.AutoRejectionCategories[0] != "EXCEPTIONALLY_FAST" { + t.Errorf("expected first category to be EXCEPTIONALLY_FAST, got %s", study.SubmissionsConfig.AutoRejectionCategories[0]) + } +} + +func TestCreateStudyWithNewOptionalFields(t *testing.T) { + jsonData := `{ + "name": "Test Study", + "internal_name": "test-study", + "description": "A test study", + "prolific_id_option": "url_parameters", + "total_available_places": 100, + "estimated_completion_time": 10, + "reward": 1.5, + "device_compatibility": ["desktop"], + "filter_set_id": "filter-set-123", + "filter_set_version": 2, + "is_custom_screening": true, + "content_warnings": ["VIOLENCE", "EXPLICIT_LANGUAGE"], + "content_warning_details": "May contain violent imagery", + "metadata": { + "project_id": "proj-123", + "researcher_notes": "Important study" + }, + "is_external_study_url_secure": true + }` + + var study model.CreateStudy + err := json.Unmarshal([]byte(jsonData), &study) + if err != nil { + t.Fatalf("failed to unmarshal CreateStudy: %v", err) + } + + if study.FilterSetID != "filter-set-123" { + t.Errorf("expected filter_set_id to be filter-set-123, got %s", study.FilterSetID) + } + if study.FilterSetVersion != 2 { + t.Errorf("expected filter_set_version to be 2, got %d", study.FilterSetVersion) + } + if !study.IsCustomScreening { + t.Error("expected is_custom_screening to be true") + } + if len(study.ContentWarnings) != 2 { + t.Fatalf("expected 2 content_warnings, got %d", len(study.ContentWarnings)) + } + if study.ContentWarningDetails != "May contain violent imagery" { + t.Errorf("expected content_warning_details to match, got %s", study.ContentWarningDetails) + } + if study.Metadata["project_id"] != "proj-123" { + t.Error("expected metadata project_id to be proj-123") + } + if !study.IsExternalStudyURLSecure { + t.Error("expected is_external_study_url_secure to be true") + } +}