import random
import json
import itertools
import time
import requests
from os import environ
from dotenv import load_dotenv
from otree.api import *
load_dotenv()
doc = """
Welfare Study: participants evaluate 6 experimental applications
(Books, Misuse of Public Funds, Surveillance, Eggs, Poem, Catfishing)
in randomized order, making choices about John's well-being.
"""
# =============================================================================
# CONSTANTS
# =============================================================================
class C(BaseConstants):
NAME_IN_URL = 'welfare_study'
PLAYERS_PER_GROUP = None
NUM_ROUNDS = 6
MIN_TIME = 25 # in minutes
MAX_TIME = 40 # in minutes
APP_NAMES = ['books', 'misuse', 'surveillance', 'eggs', 'song', 'catfishing']
WTP_VALUES = [2, 3, 5, 7, 10, 14, 20, 28, 40, 55, 75, 100, 130, 165, 200]
MS = 75 # default MS percentage
# Include file paths for Review Instructions modal
PRE_VIDEO = 'welfare_study/includes/Review-pre-vid.html'
POST_VIDEO = 'welfare_study/includes/Review-post-vid.html'
BONUS = 'welfare_study/includes/Review-bonus.html'
DETAIL = 'welfare_study/includes/Review-detail.html'
MPL = 'welfare_study/includes/Review-MPL.html'
TASK_OVERVIEW = 'welfare_study/includes/YourTask-overview.html'
TASK_DETAIL = 'welfare_study/includes/YourTask-detail.html'
# App-specific include paths
APP_INCLUDES = {
'books': 'welfare_study/includes/app_books.html',
'misuse': 'welfare_study/includes/app_misuse.html',
'surveillance': 'welfare_study/includes/app_surveillance.html',
'eggs': 'welfare_study/includes/app_eggs.html',
'song': 'welfare_study/includes/app_song.html',
'catfishing': 'welfare_study/includes/app_catfishing.html',
}
# =============================================================================
# APPLICATION CONFIGURATION
# =============================================================================
APP_CONFIG = {
'books': {
'title': 'Books',
'page_title': 'We will gift a book to John!',
'cases_title': 'Which book do we gift?',
'original_label': 'Original note',
'fake_label': 'Fake note',
'wtp_question': 'Which book do you prefer John to receive in this case?',
'original_long': 'the book with the original note',
'fake_long': 'the book with the fake note',
'item_description': 'a book by a Nobel laureate',
'item_singular': 'book',
'recipient': 'John',
'alex_pronoun': 'him',
'alex_possessive': 'his',
'alex_pronoun_subject': 'he',
'alex_gender': 'male',
'learns_question': 'whether the book he got is the one with the original or fake note',
'high_ms_item': 'original note',
'low_ms_item': 'fake note',
'description': 'We are giving a book to John. The book comes with a handwritten note—either an original note from a famous economist, or a fake note.',
'bullet_ms': "you told us that you were indifferent between John getting the book with the original note and the one with the fake note, if he doesn't learn what he gets.",
'bullet_es': "you gave the same responses in both cases. This means that whether John learns about it or not doesn't change what you want him to receive.",
'bullet_esms': "you gave different responses across cases. This means that whether John learns about it or not changes what you want him to receive.",
'cqs': {
'cq1': {
'label': 'What will John receive?',
'choices': [
'An economics book, either the one with the original handwritten note by the famous author or the one with the fake one',
'Two books, one with an original handwritten note and one with a fake one',
'Nothing',
],
'correct': 0,
},
'cq2': {
'label': 'Does John love economics?',
'choices': ['Yes', 'No'],
'correct': 0,
},
'cq3': {
'label': "What happens to the book we don't give to John?",
'choices': [
'We will keep it for ourselves',
'We will return it to the professor',
'We will destroy it',
],
'correct': 1,
},
'cq4': {
'label': 'Is it possible to tell which copy has the fake note?',
'choices': ['Yes', 'No'],
'correct': 1,
},
'cq5': {
'label': 'What do your answers determine?',
'choices': [
'Only which book John receives',
"Only John's surprise bonus",
"They determine which book John receives and his surprise bonus",
],
'correct': 2,
},
'cq6_ambiguous': {
'label': 'If we do not tell John which book he got, does he know whether he got the one with the original note or the one with the fake one?',
'choices': ['Yes', 'No'],
'correct': 1,
},
'cq6_treatments': {
'label': 'If we do not tell John which book he got, which one should he believe he is more likely to have according to the instructions we gave him?',
'choices': [
'The one with the original note',
'The one with the fake note',
],
# correct depends on treatment: high -> 0, low -> 1
},
},
},
'misuse': {
'title': 'Misuse of Public Funds',
'page_title': 'We will make a donation!',
'cases_title': 'Which charity do we donate to?',
'original_label': "John's charity",
'fake_label': "the lucky worker's charity",
'wtp_question': 'Which charity do you prefer the donation to go to in this case?',
'original_long': "the charity preferred by John",
'fake_long': "the charity preferred by the lucky worker",
'item_description': 'a $100 charity donation',
'item_singular': 'donation',
'recipient': 'John',
'alex_pronoun': 'him',
'alex_possessive': 'his',
'alex_pronoun_subject': 'he',
'alex_gender': 'male',
'learns_question': 'which charity we donated to',
'high_ms_item': "John's charity",
'low_ms_item': "the lucky worker's charity",
'description': "We are making a $100 charity donation—either to John's preferred charity or to the lucky worker's preferred charity.",
'bullet_ms': "you told us that you were indifferent between the donation going to the charity preferred by John or the donation going to the charity preferred by the lucky worker, if John doesn't learn about it.",
'bullet_es': "you gave the same responses in both cases. This means that whether John learns about it or not doesn't change which charity you want us to donate to.",
'bullet_esms': "you gave different responses across cases. This means that whether John learns about it or not changes which charity you want us to donate to.",
'cqs': {
'cq1': {
'label': 'How many donations will we make?',
'choices': [
'One $100 donation to one charity',
'Two $100 donations, one to each charity',
'No donations',
],
'correct': 0,
},
'cq2': {
'label': 'Did John have to work for 1 hour?',
'choices': ['Yes', 'No'],
'correct': 0,
},
'cq3': {
'label': 'Did the lucky worker receive less compensation than John?',
'choices': ['Yes, less', 'No, the same'],
'correct': 1,
},
'cq4': {
'label': 'Can John find out on his own which charity we donated to?',
'choices': ['Yes', 'No'],
'correct': 1,
},
'cq5': {
'label': 'What do your answers determine?',
'choices': [
'Only which charity receives the donation',
"Only John's surprise bonus",
"They determine which charity receives the donation and John's surprise bonus",
],
'correct': 2,
},
'cq6_ambiguous': {
'label': 'If we do not tell John which charity we donated to, does he know?',
'choices': ['Yes', 'No'],
'correct': 1,
},
'cq6_treatments': {
'label': "If we do not tell John, which charity should he believe is more likely to receive the donation?",
'choices': [
"John's charity",
"The lucky worker's charity",
],
},
},
},
'surveillance': {
'title': 'Surveillance',
'page_title': 'John is sharing his location with us!',
'cases_title': 'Do we monitor John?',
'original_label': 'Not monitor',
'fake_label': 'Monitor',
'wtp_question': 'Would you prefer that we monitor or not monitor John\'s location in this case?',
'original_long': 'not monitoring John\'s location',
'fake_long': 'monitoring John\'s location',
'item_description': 'location monitoring',
'item_singular': 'monitoring decision',
'recipient': 'John',
'alex_pronoun': 'him',
'alex_possessive': 'his',
'alex_pronoun_subject': 'he',
'alex_gender': 'male',
'learns_question': 'whether we monitor his location',
'high_ms_item': 'not monitor',
'low_ms_item': 'monitor',
'description': "John has shared his real-time location with us. We will either monitor his location or not.",
'bullet_ms': "you told us that you were indifferent between John being monitored and John not being monitored, if he doesn't learn about it.",
'bullet_es': "you gave the same responses in both cases. This means that whether John learns about it or not doesn't change what you want us to do.",
'bullet_esms': "you gave different responses across cases. This means that whether John learns about it or not changes what you want us to do.",
'cqs': {
'cq1': {
'label': 'What has John shared with us?',
'choices': [
'His real-time location on Google Maps',
'His phone number',
'His Facebook account',
],
'correct': 0,
},
'cq2': {
'label': 'Does John value privacy?',
'choices': ['Yes', 'No'],
'correct': 0,
},
'cq3': {
'label': 'If we monitor John\'s location, will we share it with anyone else?',
'choices': ['Yes', 'No'],
'correct': 1,
},
'cq4': {
'label': 'Can John find out on his own whether we monitor his location?',
'choices': ['Yes', 'No'],
'correct': 1,
},
'cq5': {
'label': 'What do your answers determine?',
'choices': [
'Only whether we monitor John\'s location',
"Only John's surprise bonus",
"They determine whether we monitor John's location and his surprise bonus",
],
'correct': 2,
},
'cq6_ambiguous': {
'label': 'If we do not tell John whether we monitor his location, does he know?',
'choices': ['Yes', 'No'],
'correct': 1,
},
'cq6_treatments': {
'label': 'If we do not tell John, what should he believe is more likely?',
'choices': [
'That we do not monitor his location',
'That we monitor his location',
],
},
},
},
'eggs': {
'title': 'Eggs',
'page_title': 'We will gift a carton of eggs to John!',
'cases_title': 'Which eggs do we gift?',
'original_label': 'Free-range eggs',
'fake_label': 'Caged eggs',
'wtp_question': 'Which eggs do you prefer John to receive in this case?',
'original_long': 'the free-range eggs',
'fake_long': 'the caged eggs',
'item_description': 'a carton of eggs',
'item_singular': 'eggs',
'recipient': 'John',
'alex_pronoun': 'him',
'alex_possessive': 'his',
'alex_pronoun_subject': 'he',
'alex_gender': 'male',
'learns_question': 'whether the eggs he got are free-range or caged',
'high_ms_item': 'free-range eggs',
'low_ms_item': 'caged eggs',
'description': 'We are giving a carton of eggs to John—either free-range eggs or conventional (caged) eggs.',
'bullet_ms': "you told us that you were indifferent between John getting the free-range eggs and the caged eggs, if he doesn't learn what he gets.",
'bullet_es': "you gave the same responses in both cases. This means that whether John learns about it or not doesn't change what you want him to receive.",
'bullet_esms': "you gave different responses across cases. This means that whether John learns about it or not changes what you want him to receive.",
'cqs': {
'cq1': {
'label': 'What will John receive?',
'choices': [
'A carton of eggs, either free-range or caged',
'Two cartons of eggs, one free-range and one caged',
'Nothing',
],
'correct': 0,
},
'cq2': {
'label': 'Does John care about food sourcing?',
'choices': ['Yes', 'No'],
'correct': 0,
},
'cq3': {
'label': 'Does your choice affect the hens or the farms?',
'choices': ['Yes', 'No'],
'correct': 1,
},
'cq4': {
'label': 'Can John tell the difference between the free-range and caged eggs?',
'choices': ['Yes', 'No'],
'correct': 1,
},
'cq5': {
'label': 'What do your answers determine?',
'choices': [
'Only which eggs John receives',
"Only John's surprise bonus",
"They determine which eggs John receives and his surprise bonus",
],
'correct': 2,
},
'cq6_ambiguous': {
'label': 'If we do not tell John which eggs he got, does he know whether they are free-range or caged?',
'choices': ['Yes', 'No'],
'correct': 1,
},
'cq6_treatments': {
'label': 'If we do not tell John, which eggs should he believe he is more likely to have?',
'choices': [
'The free-range eggs',
'The caged eggs',
],
},
},
},
'song': {
'title': 'Poem',
'page_title': 'We will gift a poem to John!',
'cases_title': 'Which poem do we gift?',
'original_label': 'Human-written poem',
'fake_label': 'AI-generated poem',
'wtp_question': 'Which poem do you prefer John to receive in this case?',
'original_long': 'the poem written by a human',
'fake_long': 'the poem generated by AI',
'item_description': 'a dedicated poem',
'item_singular': 'poem',
'recipient': 'John',
'alex_pronoun': 'him',
'alex_possessive': 'his',
'alex_pronoun_subject': 'he',
'alex_gender': 'male',
'learns_question': 'whether the poem he got was written by a human or generated by AI',
'high_ms_item': 'human-written poem',
'low_ms_item': 'AI-generated poem',
'description': 'We are giving a poem dedicated to John—either written by a human or generated by AI.',
'bullet_ms': "you told us that you were indifferent between John receiving the human-written poem and the AI-generated one, if he doesn't learn what he gets.",
'bullet_es': "you gave the same responses in both cases. This means that whether John learns about it or not doesn't change what you want him to get.",
'bullet_esms': "you gave different responses across cases. This means that whether John learns about it or not changes what you want him to get.",
'cqs': {
'cq1': {
'label': 'What will John receive?',
'choices': [
'One poem, either written by a human or generated by AI',
'Two poems, one human and one AI',
'Nothing',
],
'correct': 0,
},
'cq2': {
'label': 'Does John value poetry written by human authors?',
'choices': ['Yes', 'No'],
'correct': 0,
},
'cq3': {
'label': 'Can experts tell which poem was written by a human and which by AI?',
'choices': ['Yes', 'No'],
'correct': 1,
},
'cq4': {
'label': 'Can John tell on his own whether the poem was written by a human or generated by AI?',
'choices': ['Yes', 'No'],
'correct': 1,
},
'cq5': {
'label': 'What do your answers determine?',
'choices': [
'Only which poem John receives',
"Only John's surprise bonus",
"They determine which poem John receives and his surprise bonus",
],
'correct': 2,
},
'cq6_ambiguous': {
'label': 'If we do not tell John which poem he got, does he know whether it was human-written or AI-generated?',
'choices': ['Yes', 'No'],
'correct': 1,
},
'cq6_treatments': {
'label': 'If we do not tell John, which poem should he believe he is more likely to have?',
'choices': [
'The human-written poem',
'The AI-generated poem',
],
},
},
},
'catfishing': {
'title': 'Catfishing',
'page_title': 'John will have an online chat!',
'cases_title': 'Who does John chat with?',
'original_label': 'Real creator',
'fake_label': 'AI creator',
'wtp_question': 'Which chat partner do you prefer John to chat with in this case?',
'original_long': 'chatting with a real creator',
'fake_long': 'chatting with an AI creator',
'item_description': 'a 1-hour chat conversation',
'item_singular': 'chat',
'recipient': 'John',
'alex_pronoun': 'him',
'alex_possessive': 'his',
'alex_pronoun_subject': 'he',
'alex_gender': 'male',
'learns_question': 'whether he chats with a real creator or an AI creator',
'high_ms_item': 'real creator',
'low_ms_item': 'AI creator',
'description': 'John will have a 1-hour chat conversation—either with a real creator or an AI creator.',
'bullet_ms': "you told us that you were indifferent between John chatting with a real creator and an AI creator, if he doesn't learn about it.",
'bullet_es': "you gave the same responses in both cases. This means that whether John learns about it or not doesn't change who you want him to chat with.",
'bullet_esms': "you gave different responses across cases. This means that whether John learns about it or not changes who you want him to chat with.",
'cqs': {
'cq1': {
'label': 'What will John do?',
'choices': [
'Have a 1-hour chat conversation, either with a real creator or an AI creator',
'Have a 1-hour chat conversation, with both, a real creator and an AI creator',
'Nothing',
],
'correct': 0,
},
'cq2': {
'label': 'Does John enjoy chatting with real online creators?',
'choices': ['Yes', 'No'],
'correct': 0,
},
'cq3': {
'label': 'Can John tell whether he is chatting with a real creator or an AI creator?',
'choices': ['Yes', 'No'],
'correct': 1,
},
'cq4': {
'label': 'Is media allowed to be exchanged in the chat?',
'choices': ['Yes', 'No'],
'correct': 1,
},
'cq5': {
'label': 'What do your answers determine?',
'choices': [
'Only who John chats with',
"Only John's surprise bonus",
"They determine who John chats with and his surprise bonus",
],
'correct': 2,
},
'cq6_ambiguous': {
'label': 'If we do not tell John who he chats with, does he know?',
'choices': ['Yes', 'No'],
'correct': 1,
},
'cq6_treatments': {
'label': 'If we do not tell John, who should he believe he is more likely to chat with?',
'choices': [
'The real creator',
'The AI creator',
],
},
},
},
}
# =============================================================================
# MODELS
# =============================================================================
class Subsession(BaseSubsession):
pass
class Group(BaseGroup):
pass
class Player(BasePlayer):
# --- reCAPTCHA v3 ---
recaptcha_token = models.StringField(blank=True, initial='')
recaptcha_score = models.FloatField(blank=True, initial=-1)
# --- Browser info ---
browser = models.StringField(blank=True, initial='')
# --- Per-round: Comprehension questions ---
# No widget specified — choices are rendered manually in CQs.html template
# because they vary per application (loaded from APP_CONFIG)
cq1 = models.IntegerField(blank=True)
cq2 = models.IntegerField(blank=True)
cq3 = models.IntegerField(blank=True)
cq4 = models.IntegerField(blank=True)
cq5 = models.IntegerField(blank=True)
cq6_ambiguous = models.IntegerField(blank=True)
cq6_treatments = models.IntegerField(blank=True)
# CQ mistake counters
cq1_mistakes = models.IntegerField(blank=True, initial=0)
cq2_mistakes = models.IntegerField(blank=True, initial=0)
cq3_mistakes = models.IntegerField(blank=True, initial=0)
cq4_mistakes = models.IntegerField(blank=True, initial=0)
cq5_mistakes = models.IntegerField(blank=True, initial=0)
cq6_ambiguous_mistakes = models.IntegerField(blank=True, initial=0)
cq6_treatments_mistakes = models.IntegerField(blank=True, initial=0)
# --- Per-round: Elicitations ---
# All elicitation fields rendered manually in Elicitations.html — no widget needed
# Binary choice: ES = "doesn't learn" case, Trad = "learns" case
ES_wtp = models.IntegerField(blank=True)
Trad_wtp = models.IntegerField(blank=True)
# Attention checks for binary
ES_learn = models.IntegerField(blank=True)
Trad_learn = models.IntegerField(blank=True)
ES_learn_mistakes = models.IntegerField(blank=True, initial=0)
Trad_learn_mistakes = models.IntegerField(blank=True, initial=0)
# Binary +$1 choice
ES_wtp2 = models.IntegerField(blank=True)
Trad_wtp2 = models.IntegerField(blank=True)
ES_learn2 = models.IntegerField(blank=True)
Trad_learn2 = models.IntegerField(blank=True)
ES_learn2_mistakes = models.IntegerField(blank=True, initial=0)
Trad_learn2_mistakes = models.IntegerField(blank=True, initial=0)
# MPL (stored as JSON string with cutoff info)
ES_wtp3 = models.StringField(blank=True, initial='')
Trad_wtp3 = models.StringField(blank=True, initial='')
ES_learn3 = models.IntegerField(blank=True)
Trad_learn3 = models.IntegerField(blank=True)
ES_learn3_mistakes = models.IntegerField(blank=True, initial=0)
Trad_learn3_mistakes = models.IntegerField(blank=True, initial=0)
# Computed WTP values (calculated after ReviewStatements / RedoReviewStatements)
# Trad = "learns" case, ES = "doesn't learn" case
# Positive = prefers original (John-preferred), Negative = prefers fake
Trad_wtp_value = models.FloatField(blank=True)
ES_wtp_value = models.FloatField(blank=True)
# Which bullet framing was shown on MotivesInconsistency page
# 'A' = MS-focused (indifference framing), 'B' = ES-focused (same/different responses)
motive_bullet_set = models.StringField(blank=True)
# Review confirmation
confirm = models.IntegerField(
blank=True,
choices=[
[1, 'Yes, I confirm that the statements above accurately reflect my preferences.'],
[2, 'No, I would like to go back and change my answers.'],
],
widget=widgets.RadioSelect,
label='Do the statements above accurately reflect your preferences?',
)
redo_count = models.IntegerField(initial=0)
timeSpentReview = models.FloatField(blank=True)
# Review Instructions button click counts per page
review_clicks_CQs = models.IntegerField(blank=True, initial=0)
review_clicks_Cases = models.IntegerField(blank=True, initial=0)
review_clicks_Cases3 = models.IntegerField(blank=True, initial=0)
# --- Per-round: Open-ended motives ---
motives_open = models.LongStringField(initial='')
pasted_motives_open = models.BooleanField(initial=False, blank=True)
# --- Per-round: Ideals projection ---
ideals_projection = models.IntegerField()
# --- Post-loop fields (collected in round 6) ---
inconsistency_reason = models.LongStringField(blank=True, initial='')
pasted_inconsistency_reason = models.BooleanField(initial=False, blank=True)
# Doug's DG
dg_app_name = models.StringField(blank=True, initial='')
dg_wtp_amount = models.FloatField(blank=True)
dg_split_a = models.IntegerField(blank=True, min=0, max=10)
dg_split_b = models.IntegerField(blank=True, min=0, max=10)
dg_comparison = models.StringField(blank=True, initial='')
dg_option_order = models.IntegerField(blank=True) # 0=preferred first, 1=dispreferred+bonus first
dg_indifferent = models.BooleanField(choices=[[True, 'Yes'], [False, 'No']]) # True if participant is close to indifferent
# Policy questions — all rendered manually in PolicyQuestions.html
policy_norman = models.StringField(blank=True, initial='')
policy_manipulation = models.StringField(blank=True, initial='')
policy_surveillance = models.StringField(blank=True, initial='')
policy_data = models.StringField(blank=True, initial='')
policy_counterfeit = models.StringField(blank=True, initial='')
policy_nozick = models.StringField(blank=True, initial='')
policy_healthcare = models.StringField(blank=True, initial='')
policy_question_order = models.StringField(blank=True, initial='') # JSON list of 1-indexed positions, e.g. "[3,1,5,7,2,6,4]"
# A-D statements (Likert 1-5) — rendered manually in ADStatements.html
ad_statement_1 = models.IntegerField(blank=True)
ad_statement_2 = models.IntegerField(blank=True)
ad_statement_3 = models.IntegerField(blank=True)
ad_statement_4 = models.IntegerField(blank=True)
ad_statement_5 = models.IntegerField(blank=True)
ad_statement_order = models.StringField(blank=True, initial='') # JSON list of 1-indexed positions, e.g. "[4,2,5,1,3]"
# Feedback — rendered manually in Feedback.html
feedback = models.LongStringField(blank=True, initial='')
pasted_feedback = models.BooleanField(initial=False, blank=True)
feedbackDifficulty = models.IntegerField(blank=True)
feedbackUnderstanding = models.IntegerField(blank=True)
feedbackSatisfied = models.IntegerField(blank=True)
feedbackPay = models.IntegerField(blank=True)
# Timestamps
timeSubmitted_CQs = models.FloatField(blank=True)
timeSubmitted_Elicitations = models.FloatField(blank=True)
timeSubmitted_Review = models.FloatField(blank=True)
# Focus/visibility tracking (staging fields, accumulated to participant.focus_log)
_focus_blur_count = models.IntegerField(initial=0, blank=True)
_focus_blur_duration = models.FloatField(initial=0, blank=True)
_focus_vis_count = models.IntegerField(initial=0, blank=True)
_focus_vis_duration = models.FloatField(initial=0, blank=True)
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def compute_wtp_value(wtp, wtp2, wtp3):
"""
Compute the WTP value from a participant's choices in one case.
Questions are structured as:
Row 0: Original+$1 vs Fake (bonus $1 on Original side)
Row 1: Original vs Fake (no bonus)
Row 2: Original vs Fake+$2 (bonus on Fake side)
Row 3: Original vs Fake+$3
...
Row 16: Original vs Fake+$200
The "bonus values" at each row (from Fake's perspective):
[-1, 0, 2, 3, 4, 6, 9, 13, 19, 28, 40, 58, 84, 120, 155, 180, 200]
Args:
wtp: Step 1 binary choice (1=Original, 2=Fake, 3=Indifferent)
wtp2: Step 2 binary +$1 choice (1=Original, 2=Fake, 3=Indifferent)
wtp3: MPL switch row as string ("0"-"14", "0i"-"14i", "15", or "NA")
Returns a float WTP value:
Positive = prefers original (John-preferred); negative = prefers fake.
Indifferent at a row → WTP = bonus value at that row.
Switches without indifference → WTP = midpoint of adjacent values.
Never switches → WTP = max value + 1.
"""
# Bonus values (in dollars) at each row of the 17-question list.
# Row 0: Original+$1 vs Fake → bonus on Original side = $1
# Row 1: Original vs Fake → no bonus = $0
# Rows 2-16: Original vs Fake+$X → bonus on Fake side = WTP_VALUES
BONUS_VALUES = [1, 0] + C.WTP_VALUES # [1, 0, 2, 3, 4, 6, 9, 13, 19, 28, 40, 58, 84, 120, 155, 180, 200]
if wtp is None:
return None
# Determine if participant initially preferred fake (dispreferred by John)
prefers_fake = (wtp == 2)
# Compute row and indifference (same logic as ReviewStatements.vars_for_template)
if wtp == 3:
# Indifferent in Step 1 (no bonus)
row = 1
indifference = True
elif wtp == 2:
# Chose fake in Step 1
row = 0
indifference = False
if wtp2 == 3:
row = 1
indifference = True
elif wtp2 == 1:
# Switched back to original when original had +$1
row = 1
indifference = False
else:
# wtp == 1 (chose original in Step 1)
indifference = False
if wtp2 == 3:
row = 2
indifference = True
elif wtp2 == 2:
# Switched to fake at +$1 for fake (Step 2 asks Original vs Fake+$1... actually
# Step 2 is Original+$1 vs Fake). wtp2==2 means chose Fake even vs Original+$1.
# This is inconsistent (chose Original at step 1, then Fake even vs Original+$1).
# Treat as switch at row 2.
row = 2
elif wtp3 and wtp3 != 'NA':
if wtp3.endswith('i'):
try:
row = int(wtp3[:-1]) + 2
except ValueError:
row = len(C.WTP_VALUES) + 2
indifference = True
else:
try:
row = int(wtp3) + 2
except ValueError:
row = len(C.WTP_VALUES) + 2
else:
# Never switched in MPL
row = len(C.WTP_VALUES) + 2
# Calculate the WTP magnitude
if indifference:
# WTP = bonus value at the indifference row
if row < len(BONUS_VALUES):
wtp_val = BONUS_VALUES[row]
else:
wtp_val = BONUS_VALUES[-1] + 1
elif row >= len(BONUS_VALUES):
# Never switched → max value + 1
wtp_val = BONUS_VALUES[-1] + 1
elif row == 0:
# Prefers fake even when Original has +$1 → never switched on fake side
# Max probed value on fake side is $1, so WTP = $1 + 1 = $2
wtp_val = 1 + 1
else:
# Switched at this row → midpoint of adjacent bonus values
wtp_val = (BONUS_VALUES[row - 1] + BONUS_VALUES[row]) / 2
# If participant prefers fake, negate the WTP
if prefers_fake:
wtp_val = -wtp_val
return wtp_val
def compute_and_store_wtp(player):
"""Compute and store WTP values for both cases of the current round."""
trad_wtp = player.field_maybe_none('Trad_wtp')
trad_wtp2 = player.field_maybe_none('Trad_wtp2')
trad_wtp3 = player.field_maybe_none('Trad_wtp3') or ''
es_wtp = player.field_maybe_none('ES_wtp')
es_wtp2 = player.field_maybe_none('ES_wtp2')
es_wtp3 = player.field_maybe_none('ES_wtp3') or ''
player.Trad_wtp_value = compute_wtp_value(trad_wtp, trad_wtp2, trad_wtp3)
player.ES_wtp_value = compute_wtp_value(es_wtp, es_wtp2, es_wtp3)
def classify_wtp(trad_val, es_val):
"""Classify a scenario based on WTP values into ES, MS, ES+MS, or Bad."""
if trad_val is None or es_val is None:
return '—'
if trad_val > 0.5 and abs(es_val - trad_val) < 0.001:
return 'ES'
if trad_val > 0.5 and abs(es_val) < 0.001:
return 'MS'
if trad_val > 0.5 and es_val > 0.5 and abs(es_val - trad_val) >= 0.001:
return 'ES+MS'
return 'Bad'
def compute_and_store_classifications(player):
"""
Compute ES/MS/ES+MS/Bad classifications for all 6 rounds,
find last_good and second_good, and store them in participant.vars.
Must be called at round 6 after all WTP values have been computed.
"""
good_categories = {'ES', 'MS', 'ES+MS'}
summaries = []
for r in range(1, C.NUM_ROUNDS + 1):
p = player.in_round(r)
app_name = get_current_app_name(p)
config = APP_CONFIG[app_name]
trad_val = p.field_maybe_none('Trad_wtp_value')
es_val = p.field_maybe_none('ES_wtp_value')
summaries.append({
'round': r,
'app_name': app_name,
'title': config['title'],
'original_label': config['original_label'],
'fake_label': config['fake_label'],
'Trad_wtp_value': trad_val,
'ES_wtp_value': es_val,
'category': classify_wtp(trad_val, es_val),
})
# Find last_good: last scenario whose category is not "Bad" (and not "—")
last_good = None
for s in reversed(summaries):
if s['category'] in good_categories:
last_good = s
break
# Find second_good: last scenario before last_good that is not "Bad"
# and has a different classification than last_good
second_good = None
if last_good is not None:
for s in reversed(summaries):
if s['round'] >= last_good['round']:
continue
if s['category'] in good_categories and s['category'] != last_good['category']:
second_good = s
break
player.participant.vars['last_good'] = last_good
player.participant.vars['second_good'] = second_good
player.participant.vars['summaries'] = summaries
def get_current_app_name(player):
"""Return the app name (e.g. 'books') for this player's current round."""
participant = player.participant
round_idx = player.round_number - 1
app_index = participant.app_order[round_idx]
return C.APP_NAMES[app_index]
def get_current_app_config(player):
"""Return the full config dict for this player's current round application."""
return APP_CONFIG[get_current_app_name(player)]
def get_treatment_vars(player):
"""Return a dict of all treatment-dependent template variables."""
participant = player.participant
config = get_current_app_config(player)
treatment = participant.treatment
ms_pct = participant.ms_percentage
complement_pct = 100 - ms_pct
original_first = participant.choices_orders in [1, 3, 6]
# Determine what John is more likely to get
if treatment == 'high':
ms_item = config['high_ms_item']
elif treatment == 'low':
ms_item = config['low_ms_item']
else:
ms_item = None # ambiguous: no MS info shown
# Case ordering depends on switch_order
if participant.switch_order:
case1_type = 'learns'
case2_type = 'not_learns'
case1_label = 'WILL tell'
case2_label = 'WILL NOT tell'
else:
case1_type = 'not_learns'
case2_type = 'learns'
case1_label = 'WILL NOT tell'
case2_label = 'WILL tell'
# Pre-computed template helpers (oTree templates lack Django filters)
recipient = config['recipient']
recipient_cap = recipient[0].upper() + recipient[1:] if recipient else ''
# "knows" for singular (John), "know" for plural (the two workers)
recipient_knows = 'know' if recipient == 'the two workers' else 'knows'
pronoun_has = 'have' if config['alex_pronoun_subject'] == 'they' else 'has'
return {
'treatment': treatment,
'ms_pct': ms_pct,
'complement_pct': complement_pct,
'one_minus_MS': complement_pct, # alias for Review-detail.html include
'ms_item': ms_item,
'original_first': original_first,
'case1_type': case1_type,
'case2_type': case2_type,
'case1_label': case1_label,
'case2_label': case2_label,
'case1_label_upper': case1_label.upper(),
'case2_label_upper': case2_label.upper(),
'incentivized': participant.incentivized,
'choices_orders': participant.choices_orders,
'recipient_cap': recipient_cap,
'recipient_knows': recipient_knows,
'pronoun_has': pronoun_has,
}
def get_wtp_choices(player, choices_orders):
"""Return the 3-choice list in randomized order."""
config = get_current_app_config(player)
og = config['original_label']
fake = config['fake_label']
indiff = 'I am indifferent'
if choices_orders == 1:
return [[1, og], [2, fake], [3, indiff]]
elif choices_orders == 2:
return [[2, fake], [1, og], [3, indiff]]
elif choices_orders == 3:
return [[1, og], [3, indiff], [2, fake]]
elif choices_orders == 4:
return [[2, fake], [3, indiff], [1, og]]
elif choices_orders == 5:
return [[3, indiff], [2, fake], [1, og]]
elif choices_orders == 6:
return [[3, indiff], [1, og], [2, fake]]
return [[1, og], [2, fake], [3, indiff]]
# =============================================================================
# SESSION CREATION
# =============================================================================
def creating_session(subsession):
if subsession.round_number == 1:
switcheroo = [True, False]
treatments = ['ambiguous', 'high', 'low']
choices_orders_list = [1, 2, 3, 4, 5, 6]
arrays = [switcheroo, treatments, choices_orders_list]
combos = list(itertools.product(*arrays))
random.shuffle(combos)
combos_cycle = itertools.cycle(combos)
for p in subsession.get_players():
el = next(combos_cycle)
participant = p.participant
# Randomize application order
order = list(range(6))
random.shuffle(order)
participant.app_order = order
# Treatment assignments
participant.switch_order = el[0]
participant.treatment = el[1]
participant.choices_orders = el[2]
participant.ms_percentage = C.MS
participant.incentivized = random.choice([True, False])
participant.redo_elicitations = False
participant.focus_log = '{}'
# =============================================================================
# PAGES
# =============================================================================
# --- Pre-loop pages (round 1 only) ---
RECAPTCHA_SECRET_KEY = environ.get('RECAPTCHA_SECRET_KEY', '')
RECAPTCHA_SITE_KEY = environ.get('RECAPTCHA_SITE_KEY', '')
RECAPTCHA_THRESHOLD = 0.5
class Captcha(Page):
form_model = 'player'
form_fields = ['recaptcha_token']
@staticmethod
def is_displayed(player):
return player.round_number == 1
@staticmethod
def vars_for_template(player):
return dict(recaptcha_site_key=RECAPTCHA_SITE_KEY)
@staticmethod
def before_next_page(player, timeout_happened):
token = player.recaptcha_token
if not RECAPTCHA_SECRET_KEY:
player.recaptcha_score = -1
return
try:
resp = requests.post(
'https://www.google.com/recaptcha/api/siteverify',
data={'secret': RECAPTCHA_SECRET_KEY, 'response': token},
timeout=10,
)
result = resp.json()
player.recaptcha_score = result.get('score', 0.0)
except Exception:
player.recaptcha_score = -1
class Welcome(Page):
form_model = 'player'
form_fields = ['browser']
@staticmethod
def is_displayed(player):
return player.round_number == 1
@staticmethod
def vars_for_template(player):
player.participant.start_time = time.time()
@staticmethod
def before_next_page(player, timeout_happened):
if player.session.config.get('development') and player.browser == '__SKIP_LOOP__':
player.participant.skip_loop = True
# Generate fake WTP data for all 6 rounds to feed post-loop pages
for r in range(1, C.NUM_ROUNDS + 1):
p = player.in_round(r)
trad = float(random.choice([5, 10, 20, 50]))
cat = random.choice(['ES', 'MS', 'ES+MS'])
if cat == 'ES':
es = trad
elif cat == 'MS':
es = 0.0
else:
es = round(trad / 2, 1)
p.Trad_wtp_value = trad
p.ES_wtp_value = es
# Compute classifications so post-loop pages have last_good, second_good
compute_and_store_classifications(player.in_round(C.NUM_ROUNDS))
class Consent(Page):
@staticmethod
def is_displayed(player):
return player.round_number == 1
class AnnounceApps(Page):
@staticmethod
def is_displayed(player):
return player.round_number == 1
@staticmethod
def vars_for_template(player):
return {
'incentivized': player.participant.incentivized,
}
# --- Loop pages (every round) ---
class TransitionPage(Page):
@staticmethod
def is_displayed(player):
return player.round_number > 1
@staticmethod
def vars_for_template(player):
return {
'prev_round': player.round_number - 1,
'incentivized': player.participant.incentivized,
}
class Application(Page):
@staticmethod
def vars_for_template(player):
config = get_current_app_config(player)
app_name = get_current_app_name(player)
return {
'app_name': app_name,
'config': config,
'round_number': player.round_number,
}
class YourTask(Page):
@staticmethod
def vars_for_template(player):
config = get_current_app_config(player)
app_name = get_current_app_name(player)
tvars = get_treatment_vars(player)
return {
'app_name': app_name,
'config': config,
**tvars,
}
class CQs(Page):
form_model = 'player'
@staticmethod
def get_form_fields(player):
fields = ['cq1', 'cq2', 'cq3', 'cq4', 'cq5']
if player.participant.treatment == 'ambiguous':
fields.append('cq6_ambiguous')
else:
fields.append('cq6_treatments')
fields.append('review_clicks_CQs')
return fields
@staticmethod
def vars_for_template(player):
config = get_current_app_config(player)
app_name = get_current_app_name(player)
tvars = get_treatment_vars(player)
cqs = config['cqs']
cq_list = []
# Build saved values from current player fields (preserved after failed validation)
saved_values = {}
for i in range(1, 6):
key = f'cq{i}'
cq_data = cqs[key]
choices = [[j + 1, c] for j, c in enumerate(cq_data['choices'])]
cq_list.append({
'field_name': key,
'label': cq_data['label'],
'choices': choices,
})
val = player.field_maybe_none(key)
if val is not None:
saved_values[key] = val
if player.participant.treatment == 'ambiguous':
cq6 = cqs['cq6_ambiguous']
choices6 = [[j + 1, c] for j, c in enumerate(cq6['choices'])]
cq_list.append({
'field_name': 'cq6_ambiguous',
'label': cq6['label'],
'choices': choices6,
})
val = player.field_maybe_none('cq6_ambiguous')
if val is not None:
saved_values['cq6_ambiguous'] = val
else:
cq6 = cqs['cq6_treatments']
choices6 = [[j + 1, c] for j, c in enumerate(cq6['choices'])]
cq_list.append({
'field_name': 'cq6_treatments',
'label': cq6['label'],
'choices': choices6,
})
val = player.field_maybe_none('cq6_treatments')
if val is not None:
saved_values['cq6_treatments'] = val
return {
'app_name': app_name,
'config': config,
'cq_list': cq_list,
'saved_values_json': json.dumps(saved_values),
'app_include': C.APP_INCLUDES.get(app_name, ''),
**tvars,
}
@staticmethod
def error_message(player, values):
if player.session.config.get('development'):
return
config = get_current_app_config(player)
cqs = config['cqs']
error_messages = {}
for i in range(1, 6):
key = f'cq{i}'
correct_val = cqs[key]['correct'] + 1
if values.get(key) is None:
error_messages[key] = 'Please answer the question.'
elif values[key] != correct_val:
error_messages[key] = 'Please correct your answer!'
mistake_field = f'{key}_mistakes'
current = getattr(player, mistake_field) or 0
setattr(player, mistake_field, current + 1)
if player.participant.treatment == 'ambiguous':
key = 'cq6_ambiguous'
correct_val = cqs[key]['correct'] + 1
if values.get(key) is None:
error_messages[key] = 'Please answer the question.'
elif values[key] != correct_val:
error_messages[key] = 'Please correct your answer!'
player.cq6_ambiguous_mistakes = (player.cq6_ambiguous_mistakes or 0) + 1
else:
key = 'cq6_treatments'
if player.participant.treatment == 'high':
correct_val = 1
else:
correct_val = 2
if values.get(key) is None:
error_messages[key] = 'Please answer the question.'
elif values[key] != correct_val:
error_messages[key] = 'Please correct your answer!'
player.cq6_treatments_mistakes = (player.cq6_treatments_mistakes or 0) + 1
# Save submitted values to player so they persist on re-render
# (oTree doesn't save field values when error_message returns errors)
for key in ['cq1', 'cq2', 'cq3', 'cq4', 'cq5', 'cq6_ambiguous', 'cq6_treatments']:
if values.get(key) is not None:
setattr(player, key, values[key])
return error_messages
@staticmethod
def before_next_page(player, timeout_happened):
player.timeSubmitted_CQs = time.time()
class IntroduceMPL(Page):
@staticmethod
def vars_for_template(player):
config = get_current_app_config(player)
app_name = get_current_app_name(player)
tvars = get_treatment_vars(player)
return {
'app_name': app_name,
'config': config,
'wtp_values': C.WTP_VALUES,
**tvars,
}
class SuggestConsiderations(Page):
@staticmethod
def vars_for_template(player):
config = get_current_app_config(player)
app_name = get_current_app_name(player)
tvars = get_treatment_vars(player)
return {
'app_name': app_name,
'config': config,
**tvars,
}
class Cases(Page):
"""Step 1: Binary choice (Original / Fake / Indifferent) with comprehension check."""
form_model = 'player'
form_fields = [
'ES_wtp', 'Trad_wtp',
'ES_learn', 'Trad_learn',
'ES_learn_mistakes', 'Trad_learn_mistakes',
'review_clicks_Cases',
]
@staticmethod
def vars_for_template(player):
config = get_current_app_config(player)
app_name = get_current_app_name(player)
tvars = get_treatment_vars(player)
choices_orders = player.participant.choices_orders
wtp_choices = get_wtp_choices(player, choices_orders)
return {
'app_name': app_name,
'config': config,
'wtp_choices': wtp_choices,
'app_include': C.APP_INCLUDES.get(app_name, ''),
**tvars,
}
@staticmethod
def error_message(player, values):
if player.session.config.get('development'):
return
error_messages = {}
if values.get('Trad_learn') is None:
error_messages['Trad_learn'] = 'Please answer the comprehension question.'
elif values['Trad_learn'] != 1:
error_messages['Trad_learn'] = 'Please correct your answer!'
if values.get('ES_learn') is None:
error_messages['ES_learn'] = 'Please answer the comprehension question.'
elif values['ES_learn'] != 2:
error_messages['ES_learn'] = 'Please correct your answer!'
if values.get('Trad_wtp') is None:
error_messages['Trad_wtp'] = 'Please make a choice.'
if values.get('ES_wtp') is None:
error_messages['ES_wtp'] = 'Please make a choice.'
return error_messages
@staticmethod
def before_next_page(player, timeout_happened):
if player.session.config.get('development'):
if player.field_maybe_none('Trad_wtp') is None:
player.Trad_wtp = 1 # always prefer original (WTP Learns > 0.5)
if player.field_maybe_none('ES_wtp') is None:
# Random: 1=original (→ ES or ES+MS), 3=indifferent (→ MS)
player.ES_wtp = random.choice([1, 1, 3])
class Cases2(Page):
"""Step 2: Binary +$1 choice with indifference option and comprehension check."""
form_model = 'player'
form_fields = [
'ES_wtp2', 'Trad_wtp2',
'ES_learn2', 'Trad_learn2',
'ES_learn2_mistakes', 'Trad_learn2_mistakes',
]
@staticmethod
def vars_for_template(player):
config = get_current_app_config(player)
app_name = get_current_app_name(player)
tvars = get_treatment_vars(player)
choices_orders = player.participant.choices_orders
return {
'app_name': app_name,
'config': config,
'Trad_wtp': player.Trad_wtp,
'ES_wtp': player.ES_wtp,
**tvars,
}
@staticmethod
def error_message(player, values):
if player.session.config.get('development'):
return
error_messages = {}
# Only validate if the case is not skipped (i.e., Step 1 was not indifferent)
if player.Trad_wtp != 3:
if values.get('Trad_learn2') is None:
error_messages['Trad_learn2'] = 'Please answer the comprehension question.'
elif values['Trad_learn2'] != 1:
error_messages['Trad_learn2'] = 'Please correct your answer!'
if values.get('Trad_wtp2') is None:
error_messages['Trad_wtp2'] = 'Please make a choice.'
if player.ES_wtp != 3:
if values.get('ES_learn2') is None:
error_messages['ES_learn2'] = 'Please answer the comprehension question.'
elif values['ES_learn2'] != 2:
error_messages['ES_learn2'] = 'Please correct your answer!'
if values.get('ES_wtp2') is None:
error_messages['ES_wtp2'] = 'Please make a choice.'
return error_messages
@staticmethod
def before_next_page(player, timeout_happened):
if player.session.config.get('development'):
if player.Trad_wtp != 3 and player.field_maybe_none('Trad_wtp2') is None:
player.Trad_wtp2 = 1 # default: chose original again
if player.ES_wtp != 3 and player.field_maybe_none('ES_wtp2') is None:
player.ES_wtp2 = 1 # default: chose original again
class Cases3Explained(Page):
"""MPL explanation page — no form fields."""
@staticmethod
def is_displayed(player):
# Only show if at least one case will have an active MPL table on Cases3
trad_wtp2 = player.field_maybe_none('Trad_wtp2')
es_wtp2 = player.field_maybe_none('ES_wtp2')
trad_active = player.Trad_wtp == 1 and trad_wtp2 == 1
es_active = player.ES_wtp == 1 and es_wtp2 == 1
return trad_active or es_active
@staticmethod
def vars_for_template(player):
config = get_current_app_config(player)
app_name = get_current_app_name(player)
tvars = get_treatment_vars(player)
return {
'app_name': app_name,
'config': config,
**tvars,
}
class Cases3(Page):
"""Step 3: MPL table with indifference, shown only when 'strict' (chose original in Steps 1+2)."""
form_model = 'player'
form_fields = [
'ES_wtp3', 'Trad_wtp3',
'ES_learn3', 'Trad_learn3',
'ES_learn3_mistakes', 'Trad_learn3_mistakes',
'review_clicks_Cases3',
]
@staticmethod
def vars_for_template(player):
config = get_current_app_config(player)
app_name = get_current_app_name(player)
tvars = get_treatment_vars(player)
choices_orders = player.participant.choices_orders
original_first = choices_orders in [1, 3, 6]
return {
'app_name': app_name,
'config': config,
'app_include': C.APP_INCLUDES.get(app_name, ''),
'wtp_values_json': json.dumps(C.WTP_VALUES),
'original_first': original_first,
'original_first_json': json.dumps(original_first),
'Trad_wtp': player.Trad_wtp,
'ES_wtp': player.ES_wtp,
'Trad_wtp2': player.field_maybe_none('Trad_wtp2'),
'ES_wtp2': player.field_maybe_none('ES_wtp2'),
**tvars,
}
@staticmethod
def error_message(player, values):
if player.session.config.get('development'):
return
error_messages = {}
# Only validate MPL if the case is strict (original in both Steps 1 and 2)
trad_wtp2 = player.field_maybe_none('Trad_wtp2')
es_wtp2 = player.field_maybe_none('ES_wtp2')
if player.Trad_wtp == 1 and trad_wtp2 == 1:
if values.get('Trad_learn3') is None:
error_messages['Trad_learn3'] = 'Please answer the comprehension question.'
elif values['Trad_learn3'] != 1:
error_messages['Trad_learn3'] = 'Please correct your answer!'
if not values.get('Trad_wtp3'):
error_messages['Trad_wtp3'] = 'Please complete the table.'
if player.ES_wtp == 1 and es_wtp2 == 1:
if values.get('ES_learn3') is None:
error_messages['ES_learn3'] = 'Please answer the comprehension question.'
elif values['ES_learn3'] != 2:
error_messages['ES_learn3'] = 'Please correct your answer!'
if not values.get('ES_wtp3'):
error_messages['ES_wtp3'] = 'Please complete the table.'
return error_messages
@staticmethod
def before_next_page(player, timeout_happened):
# In development mode, fill in default MPL data if missing
if player.session.config.get('development'):
trad_wtp2 = player.field_maybe_none('Trad_wtp2')
es_wtp2 = player.field_maybe_none('ES_wtp2')
if player.Trad_wtp == 1 and trad_wtp2 == 1 and not player.Trad_wtp3:
player.Trad_wtp3 = str(random.randint(5, 10))
if player.ES_wtp == 1 and es_wtp2 == 1 and not player.ES_wtp3:
# 50% same as Trad (→ ES), 50% lower (→ ES+MS)
if random.random() < 0.5 and player.Trad_wtp3 and player.Trad_wtp3 != 'NA':
player.ES_wtp3 = player.Trad_wtp3
else:
player.ES_wtp3 = str(random.randint(0, 4))
# Set wtp3 to 'NA' for skipped (non-strict) cases
trad_wtp2 = player.field_maybe_none('Trad_wtp2')
es_wtp2 = player.field_maybe_none('ES_wtp2')
if not (player.Trad_wtp == 1 and trad_wtp2 == 1):
player.Trad_wtp3 = 'NA'
if not (player.ES_wtp == 1 and es_wtp2 == 1):
player.ES_wtp3 = 'NA'
player.timeSubmitted_Elicitations = time.time()
class ReviewStatements(Page):
"""Review page showing all preference statements from Steps 1-3."""
form_model = 'player'
form_fields = ['confirm', 'timeSpentReview']
@staticmethod
def vars_for_template(player):
config = get_current_app_config(player)
app_name = get_current_app_name(player)
tvars = get_treatment_vars(player)
choices_orders = player.participant.choices_orders
original_first = choices_orders in [1, 3, 6]
# Compute row/indifference for Trad case
trad_wtp = player.field_maybe_none('Trad_wtp')
trad_wtp2 = player.field_maybe_none('Trad_wtp2')
trad_wtp3 = player.field_maybe_none('Trad_wtp3') or ''
if trad_wtp is None:
# Development skip — no choices made; default to row 0, no indifference
Trad_row = 0
Trad_indifference = False
elif trad_wtp == 3:
# Indifferent in Step 1 → row=1, indifference=True
Trad_row = 1
Trad_indifference = True
elif trad_wtp == 2:
# Chose fake in Step 1 → row=0 (prefer fake from the start)
Trad_row = 0
Trad_indifference = False
if trad_wtp2 == 3:
Trad_row = 1
Trad_indifference = True
elif trad_wtp2 == 1:
Trad_row = 1
Trad_indifference = False
else:
# Chose original in Step 1
Trad_indifference = False
if trad_wtp2 == 3:
# Indifferent at +$1
Trad_row = 2
Trad_indifference = True
elif trad_wtp2 == 2:
# Switched to fake at +$1
Trad_row = 2
elif trad_wtp3 and trad_wtp3 != 'NA':
# MPL switch row (offset by 3 for the 3 pre-MPL rows: +$1/original, no-bonus, fake+$1)
# Values ending in 'i' indicate indifference at that row
if trad_wtp3.endswith('i'):
try:
Trad_row = int(trad_wtp3[:-1]) + 3
except ValueError:
Trad_row = len(C.WTP_VALUES) + 3
Trad_indifference = True
else:
try:
Trad_row = int(trad_wtp3) + 3
except ValueError:
Trad_row = len(C.WTP_VALUES) + 3
else:
# Never switched
Trad_row = len(C.WTP_VALUES) + 3
# Compute row/indifference for ES case
es_wtp = player.field_maybe_none('ES_wtp')
es_wtp2 = player.field_maybe_none('ES_wtp2')
es_wtp3 = player.field_maybe_none('ES_wtp3') or ''
if es_wtp is None:
# Development skip — no choices made; default to row 0, no indifference
ES_row = 0
ES_indifference = False
elif es_wtp == 3:
ES_row = 1
ES_indifference = True
elif es_wtp == 2:
ES_row = 0
ES_indifference = False
if es_wtp2 == 3:
ES_row = 1
ES_indifference = True
elif es_wtp2 == 1:
ES_row = 1
ES_indifference = False
else:
ES_indifference = False
if es_wtp2 == 3:
ES_row = 2
ES_indifference = True
elif es_wtp2 == 2:
ES_row = 2
elif es_wtp3 and es_wtp3 != 'NA':
# Values ending in 'i' indicate indifference at that row
if es_wtp3.endswith('i'):
try:
ES_row = int(es_wtp3[:-1]) + 3
except ValueError:
ES_row = len(C.WTP_VALUES) + 3
ES_indifference = True
else:
try:
ES_row = int(es_wtp3) + 3
except ValueError:
ES_row = len(C.WTP_VALUES) + 3
else:
ES_row = len(C.WTP_VALUES) + 3
return {
'app_name': app_name,
'config': config,
'wtp_values_json': json.dumps(C.WTP_VALUES),
'original_first_json': json.dumps(original_first),
'Trad_row': Trad_row,
'Trad_indifference_json': json.dumps(Trad_indifference),
'ES_row': ES_row,
'ES_indifference_json': json.dumps(ES_indifference),
'Trad_wtp': trad_wtp,
'ES_wtp': es_wtp,
**tvars,
}
@staticmethod
def before_next_page(player, timeout_happened):
player.timeSubmitted_Review = time.time()
if player.session.config.get('development') and player.field_maybe_none('confirm') is None:
player.confirm = 1 # default: confirm in dev mode
# Compute WTP before potentially resetting fields
compute_and_store_wtp(player)
if player.confirm == 2:
player.participant.redo_elicitations = True
player.redo_count += 1
# Reset all elicitation fields so participant can re-enter them
player.Trad_wtp = None
player.ES_wtp = None
player.Trad_wtp2 = None
player.ES_wtp2 = None
player.Trad_wtp3 = ''
player.ES_wtp3 = ''
player.Trad_learn = None
player.ES_learn = None
player.Trad_learn2 = None
player.ES_learn2 = None
player.Trad_learn3 = None
player.ES_learn3 = None
player.Trad_learn_mistakes = 0
player.ES_learn_mistakes = 0
player.Trad_learn2_mistakes = 0
player.ES_learn2_mistakes = 0
player.Trad_learn3_mistakes = 0
player.ES_learn3_mistakes = 0
player.confirm = None
# --- Redo pages (displayed only when participant chose "No" on ReviewStatements) ---
class RedoCases(Cases):
template_name = 'welfare_study/Cases.html'
@staticmethod
def is_displayed(player):
return player.participant.redo_elicitations
class RedoCases2(Cases2):
template_name = 'welfare_study/Cases2.html'
@staticmethod
def is_displayed(player):
return player.participant.redo_elicitations
class RedoCases3Explained(Cases3Explained):
template_name = 'welfare_study/Cases3Explained.html'
@staticmethod
def is_displayed(player):
if not player.participant.redo_elicitations:
return False
# Only show if at least one case will have an active MPL table
trad_wtp2 = player.field_maybe_none('Trad_wtp2')
es_wtp2 = player.field_maybe_none('ES_wtp2')
trad_active = player.Trad_wtp == 1 and trad_wtp2 == 1
es_active = player.ES_wtp == 1 and es_wtp2 == 1
return trad_active or es_active
class RedoCases3(Cases3):
template_name = 'welfare_study/Cases3.html'
@staticmethod
def is_displayed(player):
return player.participant.redo_elicitations
@staticmethod
def before_next_page(player, timeout_happened):
# Do NOT clear redo flag here — RedoReviewStatements handles that
# In development mode, fill in default MPL data if missing
if player.session.config.get('development'):
trad_wtp2 = player.field_maybe_none('Trad_wtp2')
es_wtp2 = player.field_maybe_none('ES_wtp2')
if player.Trad_wtp == 1 and trad_wtp2 == 1 and not player.Trad_wtp3:
player.Trad_wtp3 = '7'
if player.ES_wtp == 1 and es_wtp2 == 1 and not player.ES_wtp3:
player.ES_wtp3 = '7'
# Replicate parent logic: mark skipped cases as NA
trad_wtp2 = player.field_maybe_none('Trad_wtp2')
es_wtp2 = player.field_maybe_none('ES_wtp2')
if not (player.Trad_wtp == 1 and trad_wtp2 == 1):
player.Trad_wtp3 = 'NA'
if not (player.ES_wtp == 1 and es_wtp2 == 1):
player.ES_wtp3 = 'NA'
player.timeSubmitted_Elicitations = time.time()
class RedoReviewStatements(ReviewStatements):
template_name = 'welfare_study/ReviewStatements.html'
@staticmethod
def is_displayed(player):
return player.participant.redo_elicitations
@staticmethod
def before_next_page(player, timeout_happened):
player.participant.redo_elicitations = False
player.timeSubmitted_Review = time.time()
if player.session.config.get('development') and player.field_maybe_none('confirm') is None:
player.confirm = 1
# Recompute WTP with the redo'd choices
compute_and_store_wtp(player)
if player.confirm == 2:
# They said "No" again on the redo — increment redo count but
# we can't loop again in oTree's linear page sequence.
# Just proceed forward.
player.redo_count += 1
class MotivesOpen(Page):
form_model = 'player'
form_fields = ['motives_open', 'pasted_motives_open']
@staticmethod
def vars_for_template(player):
config = get_current_app_config(player)
app_name = get_current_app_name(player)
return {
'app_name': app_name,
'config': config,
}
class IdealsProjection(Page):
form_model = 'player'
form_fields = ['ideals_projection']
@staticmethod
def vars_for_template(player):
config = get_current_app_config(player)
app_name = get_current_app_name(player)
ideals_choices = [
(1, 'Strongly disagree'),
(2, 'Disagree'),
(3, 'Neither agree nor disagree'),
(4, 'Agree'),
(5, 'Strongly agree'),
]
return {
'app_name': app_name,
'config': config,
'ideals_choices': ideals_choices,
}
@staticmethod
def before_next_page(player, timeout_happened):
# At the end of round 6, compute classifications and store last_good/second_good
# so they're available for MotivesInconsistency (even if WTPSummary is hidden)
if player.round_number == C.NUM_ROUNDS:
compute_and_store_classifications(player)
# --- Post-loop pages (round 6 only) ---
class WTPSummary(Page):
"""Displays computed WTP values for all 6 scenarios (dev mode only)."""
@staticmethod
def is_displayed(player):
return player.round_number == C.NUM_ROUNDS and player.session.config.get('development')
@staticmethod
def vars_for_template(player):
def fmt_wtp(val):
if val is None:
return '—'
# Display as integer if whole number, otherwise one decimal
if val == int(val):
return '${}'.format(int(val))
return '${:.1f}'.format(val)
# Use pre-computed summaries from IdealsProjection.before_next_page
summaries = player.participant.vars.get('summaries', [])
# Add display-formatted WTP values for the template
for s in summaries:
s['Trad_wtp_display'] = fmt_wtp(s.get('Trad_wtp_value'))
s['ES_wtp_display'] = fmt_wtp(s.get('ES_wtp_value'))
last_good = player.participant.vars.get('last_good')
second_good = player.participant.vars.get('second_good')
return {
'summaries': summaries,
'last_good': last_good,
'second_good': second_good,
}
class MotivesInconsistency(Page):
form_model = 'player'
form_fields = ['inconsistency_reason', 'pasted_inconsistency_reason']
@staticmethod
def error_message(player, values):
if player.session.config.get('development'):
return
if not values.get('inconsistency_reason', '').strip():
return 'Please explain your reasoning before continuing.'
@staticmethod
def is_displayed(player):
if player.round_number != C.NUM_ROUNDS:
return False
last_good = player.participant.vars.get('last_good')
second_good = player.participant.vars.get('second_good')
return last_good is not None and second_good is not None
@staticmethod
def vars_for_template(player):
last_good = player.participant.vars.get('last_good')
second_good = player.participant.vars.get('second_good')
lg_config = APP_CONFIG[last_good['app_name']]
sg_config = APP_CONFIG[second_good['app_name']]
lg_cat = last_good['category']
sg_cat = second_good['category']
categories = {lg_cat, sg_cat}
# Determine which bullet framing to use (stable across refreshes)
existing = player.field_maybe_none('motive_bullet_set')
if existing and existing != '':
bullet_set = existing
else:
if categories == {'MS', 'ES+MS'}:
bullet_set = 'A' # MS-focused (indifference framing)
elif categories == {'ES', 'ES+MS'}:
bullet_set = 'B' # ES-focused (same/different responses)
elif categories == {'ES', 'MS'}:
bullet_set = random.choice(['A', 'B'])
else:
bullet_set = 'A' # fallback
player.motive_bullet_set = bullet_set
# Build bullet texts by looking up the exact text per scenario + category
# Each config has bullet_ms, bullet_es, bullet_esms keyed by category
cat_to_key = {'MS': 'bullet_ms', 'ES': 'bullet_es', 'ES+MS': 'bullet_esms'}
def make_bullet(scenario, config):
key = cat_to_key[scenario['category']]
return "In scenario {}, {}".format(scenario['round'], config[key])
# For {ES, MS} with random framing, we need to relabel one scenario:
# Framing A: treat ES as ES+MS (show its esms bullet)
# Framing B: treat MS as ES+MS (show its esms bullet)
if categories == {'ES', 'MS'} and bullet_set == 'A':
# MS gets its MS bullet, ES gets the ES+MS bullet
lg_bullet_key = cat_to_key[lg_cat] if lg_cat == 'MS' else 'bullet_esms'
sg_bullet_key = cat_to_key[sg_cat] if sg_cat == 'MS' else 'bullet_esms'
bullet_lg = "In scenario {}, {}".format(last_good['round'], lg_config[lg_bullet_key])
bullet_sg = "In scenario {}, {}".format(second_good['round'], sg_config[sg_bullet_key])
elif categories == {'ES', 'MS'} and bullet_set == 'B':
# ES gets its ES bullet, MS gets the ES+MS bullet
lg_bullet_key = cat_to_key[lg_cat] if lg_cat == 'ES' else 'bullet_esms'
sg_bullet_key = cat_to_key[sg_cat] if sg_cat == 'ES' else 'bullet_esms'
bullet_lg = "In scenario {}, {}".format(last_good['round'], lg_config[lg_bullet_key])
bullet_sg = "In scenario {}, {}".format(second_good['round'], sg_config[sg_bullet_key])
else:
# {MS, ES+MS} or {ES, ES+MS}: use each scenario's own category bullet
bullet_lg = make_bullet(last_good, lg_config)
bullet_sg = make_bullet(second_good, sg_config)
# Order: chronological (second_good is the earlier round)
bullet1 = bullet_sg
bullet2 = bullet_lg
# Task item descriptions for the YourTask section in modals
task_items = {
'books': 'Which book (original or fake note) John receives',
'eggs': 'Whether John receives the free-range or caged eggs',
'surveillance': "Whether we monitor John's location",
'misuse': "Whether the $100 donation goes to the charity John chose",
'song': 'Whether John receives the human-written or AI-generated poem',
'catfishing': 'Whether John chats with the real creator or the AI creator',
}
# Treatment variables (participant-level)
participant = player.participant
if participant.switch_order:
case1_label = 'WILL tell'
case2_label = 'WILL NOT tell'
else:
case1_label = 'WILL NOT tell'
case2_label = 'WILL tell'
return {
'last_good': last_good,
'second_good': second_good,
'lg_config': lg_config,
'sg_config': sg_config,
'lg_app_name': last_good['app_name'],
'sg_app_name': second_good['app_name'],
'lg_task_item': task_items[last_good['app_name']],
'sg_task_item': task_items[second_good['app_name']],
'bullet1': bullet1,
'bullet2': bullet2,
'bullet_set': bullet_set,
'case1_label': case1_label,
'case2_label': case2_label,
'incentivized': participant.incentivized,
'treatment': participant.treatment,
'ms_pct': participant.ms_percentage,
'complement_pct': 100 - participant.ms_percentage,
}
class DougsDG(Page):
form_model = 'player'
form_fields = ['dg_split_a', 'dg_split_b', 'dg_comparison', 'dg_indifferent']
@staticmethod
def error_message(player, values):
if player.session.config.get('development'):
return
if (values.get('dg_split_a') is None or
values.get('dg_split_b') is None or
not values.get('dg_comparison', '').strip()):
return 'Please answer all the questions on this page.'
@staticmethod
def is_displayed(player):
if player.round_number != C.NUM_ROUNDS:
return False
return player.participant.vars.get('last_good') is not None
@staticmethod
def vars_for_template(player):
# Per-scenario option descriptions for the DG
# 'preferred' = original (John-preferred) option
# 'dispreferred' / 'dispreferred_with_x' = fake option (± bonus)
DG_OPTIONS = {
'books': {
'preferred': 'John getting the book with the original note',
'dispreferred': 'John getting the book with the fake note',
'dispreferred_with_x': 'John getting the book with the fake note and {x}',
},
'eggs': {
'preferred': 'John getting the free-range eggs',
'dispreferred': 'John getting the caged eggs',
'dispreferred_with_x': 'John getting the caged eggs and {x}',
},
'surveillance': {
'preferred': "We do not monitor John's location",
'dispreferred': "We monitor John's location",
'dispreferred_with_x': "We monitor John's location, and he receives {x}",
},
'misuse': {
'preferred': "The donation goes to John's charity",
'dispreferred': "The donation goes to the lucky worker's charity",
'dispreferred_with_x': "The donation goes to the lucky worker's charity, and John receives {x}",
},
'song': {
'preferred': 'John getting the human-written poem',
'dispreferred': 'John getting the AI-generated poem',
'dispreferred_with_x': 'John getting the AI-generated poem and {x}',
},
'catfishing': {
'preferred': 'John chatting with the real creator',
'dispreferred': 'John chatting with the AI creator',
'dispreferred_with_x': 'John chatting with the AI creator and receiving {x}',
},
}
last_good = player.participant.vars['last_good']
app_name = last_good['app_name']
config = APP_CONFIG[app_name]
x_val = last_good['ES_wtp_value']
# Store for data export
player.dg_app_name = app_name
player.dg_wtp_amount = x_val
# Format $X
if x_val == int(x_val):
x_display = '${}'.format(int(x_val))
else:
x_display = '${:.1f}'.format(x_val)
# Build option texts
opts = DG_OPTIONS[app_name]
preferred_text = opts['preferred']
if x_val > 0:
dispreferred_text = opts['dispreferred_with_x'].format(x=x_display)
else:
dispreferred_text = opts['dispreferred']
# Randomize option order: 50% preferred first, 50% dispreferred+bonus first
# Stable across page refreshes
existing_order = player.field_maybe_none('dg_option_order')
if existing_order is not None:
option_order = existing_order
else:
option_order = random.choice([0, 1])
player.dg_option_order = option_order
if option_order == 0:
# Preferred first
option_a_text = preferred_text
option_b_text = dispreferred_text
else:
# Dispreferred + bonus first
option_a_text = dispreferred_text
option_b_text = preferred_text
return {
'dg_round': last_good['round'],
'dg_config': config,
'dg_app_name': app_name,
'x_display': x_display,
'option_a_text': option_a_text,
'option_b_text': option_b_text,
'incentivized': player.participant.incentivized,
}
POLICY_QUESTIONS = [
{
'field': 'policy_norman',
'title': 'Question',
'emoji': '🌊',
'color': '#d0e8ff',
'paragraphs': [
'A small town in Arkansas experiences massive flooding, leaving many families homeless. '
'To provide financial assistance for the impacted families, the government raises taxes, '
'including a $100 levy on Norman. As a general matter, Norman thinks government spending is wasteful, '
'but he is also an altruist and would gladly contribute $100 to the fund if he knew about it. '
'However, he never learns about the flood or the relief effort.',
],
'prompt': 'Does the government\'s policy make him better off or worse off?',
},
{
'field': 'policy_manipulation',
'title': 'Question',
'emoji': '📋',
'color': '#fff3cd',
'paragraphs': [
'Governments sometimes influence people\'s choices by subtly changing how options are presented, '
'in ways that people never notice or later learn about.',
'For example, a government might arrange the order and layout of options on a mandatory tax or benefits '
'form so that one option is chosen more often, even though people never realize that the design of the form '
'influenced their decision and never learn that the form could have been designed differently.',
'As a result, people end up making choices they likely would not have made if the options had been '
'presented differently, while never feeling manipulated or aware that anything unusual occurred.',
],
'prompt': 'Does this practice make people better off or worse off?',
},
{
'field': 'policy_surveillance',
'title': 'Question',
'emoji': '👁️',
'color': '#e2d9f3',
'paragraphs': [
'Governments sometimes engage in large-scale surveillance of digital communications for security purposes. '
'For example, programs like the U.S. government\'s PRISM involved the collection and analysis of emails, '
'messages, and other online data from large numbers of ordinary citizens, even when they were not suspected of any wrongdoing.',
'Suppose the government introduces a new covert surveillance program that operates without the public\'s knowledge, '
'so people remain unaware of its existence.',
],
'prompt': 'Does the program make people better off or worse off?',
},
{
'field': 'policy_data',
'title': 'Question',
'emoji': '📱',
'color': '#d4edda',
'paragraphs': [
'Private technology companies sometimes collect and sell users\' personal data\u2009\u2014\u2009such as browsing history, '
'location information, purchase records, and messages\u2009\u2014\u2009for commercial purposes.',
'In many cases, users are unaware that this is happening. Many people value their privacy and, '
'if they knew their data were being collected and sold in this way, would not like it.',
],
'prompt': 'Does this kind of data collection and sale make people better off or worse off?',
},
{
'field': 'policy_counterfeit',
'title': 'Question',
'emoji': '👟',
'color': '#fde2e2',
'paragraphs': [
'Imagine that a person purchases a product\u2009\u2014\u2009for example, a pair of designer shoes, an electronic device, '
'or a branded accessory\u2009\u2014\u2009but the item turns out to be a counterfeit (a fake).',
'The product works exactly as expected. The person uses it normally and never discovers that it is counterfeit. '
'The only difference is that the product was not genuinely produced by the brand it appears to come from.',
],
'prompt': 'Does the fact that the shoes are fake make the person better off or worse off if they never learn about it?',
},
{
'field': 'policy_nozick',
'title': 'Question',
'emoji': '🧠',
'color': '#e0f0ff',
'paragraphs': [
'Imagine a machine existed that could give a person any experiences they desired\u2014love, achievement, '
'adventure\u2014all completely indistinguishable from reality. Once connected, the person would have no idea they were in a machine.',
'Suppose someone is connected to such a machine without their knowledge. From the inside, their life feels rich, '
'meaningful, and fulfilling. They experience deep relationships, personal accomplishments, and genuine happiness. '
'However, none of it is real\u2014their body is simply connected to the machine, and the people and events they experience do not exist.',
],
'prompt': 'Does being connected to the experience machine make this person better off or worse off?',
},
{
'field': 'policy_healthcare',
'title': 'Question',
'emoji': '🏥',
'color': '#d1ecf1',
'paragraphs': [
'Imagine a person who visits a clinic because they have a medical concern. They provide detailed information '
'about their symptoms, medical history, and test results. In general, this person does not like AI systems '
'and would prefer to receive care from a human physician, with no AI involved.',
'In the case at hand, the information is evaluated by an AI system. The AI-generated diagnosis is correct '
'and leads to appropriate treatment. However, the person is not told that the diagnosis was produced by an AI system. '
'They falsely believe that a human physician reviewed their case and they never learn otherwise.',
],
'prompt': "Does the fact that the diagnosis was generated by AI—without the patient's knowledge—make the patient better or worse off?",
},
]
class PolicyQuestions(Page):
form_model = 'player'
form_fields = [
'policy_norman', 'policy_manipulation', 'policy_surveillance',
'policy_data', 'policy_counterfeit', 'policy_nozick', 'policy_healthcare',
]
@staticmethod
def error_message(player, values):
if player.session.config.get('development'):
return
error_messages = {}
for field in [
'policy_norman', 'policy_manipulation', 'policy_surveillance',
'policy_data', 'policy_counterfeit', 'policy_nozick', 'policy_healthcare',
]:
if not values.get(field, '').strip():
error_messages[field] = 'Please answer this question.'
return error_messages
@staticmethod
def is_displayed(player):
return player.round_number == C.NUM_ROUNDS
@staticmethod
def vars_for_template(player):
policy_choices = [
('much_better', 'Much better off'),
('slightly_better', 'Slightly better off'),
('neither', 'Neither better nor worse off'),
('slightly_worse', 'Slightly worse off'),
('much_worse', 'Much worse off'),
]
# Randomize order (stable across refreshes)
if not player.policy_question_order:
order = list(range(len(POLICY_QUESTIONS)))
random.shuffle(order)
player.policy_question_order = json.dumps(order)
order = json.loads(player.policy_question_order)
questions = []
for display_num, orig_idx in enumerate(order, 1):
q = dict(POLICY_QUESTIONS[orig_idx])
q['display_num'] = display_num
questions.append(q)
return {
'policy_choices': policy_choices,
'questions': questions,
}
AD_STATEMENTS = [
{
'field': 'ad_statement_1',
'text': '"What would be best for someone is what, throughout their life, would best fulfil their desires."',
'attribution': '',
},
{
'field': 'ad_statement_2',
'text': '"We want to do certain things, and not just have the experience of doing them."',
'attribution': 'Robert Nozick',
},
{
'field': 'ad_statement_3',
'text': '"There is no more truth than there is in the world you created for yourself."',
'attribution': 'The Truman Show (1998)',
},
{
'field': 'ad_statement_4',
'text': '"What you don\'t know can\'t hurt you."',
'attribution': '',
},
{
'field': 'ad_statement_5',
'text': '"What would be best for someone is what would make their life happiest."',
'attribution': '',
},
]
class ADStatements(Page):
form_model = 'player'
form_fields = [
'ad_statement_1', 'ad_statement_2', 'ad_statement_3',
'ad_statement_4', 'ad_statement_5',
]
@staticmethod
def error_message(player, values):
if player.session.config.get('development'):
return
error_messages = {}
for field in [
'ad_statement_1', 'ad_statement_2', 'ad_statement_3',
'ad_statement_4', 'ad_statement_5',
]:
if values.get(field) is None:
error_messages[field] = 'Please answer this question.'
return error_messages
@staticmethod
def is_displayed(player):
return player.round_number == C.NUM_ROUNDS
@staticmethod
def vars_for_template(player):
likert_values = [
(1, 'Strongly disagree'),
(2, 'Disagree'),
(3, 'Neither'),
(4, 'Agree'),
(5, 'Strongly agree'),
]
# Randomize order (stable across refreshes)
if not player.ad_statement_order:
order = list(range(len(AD_STATEMENTS)))
random.shuffle(order)
player.ad_statement_order = json.dumps(order)
order = json.loads(player.ad_statement_order)
statements = [AD_STATEMENTS[i] for i in order]
return {
'likert_values': likert_values,
'statements': statements,
}
class Feedback(Page):
form_model = 'player'
form_fields = ['feedback', 'pasted_feedback', 'feedbackDifficulty', 'feedbackUnderstanding',
'feedbackSatisfied', 'feedbackPay']
@staticmethod
def is_displayed(player):
return player.round_number == C.NUM_ROUNDS
@staticmethod
def vars_for_template(player):
return {'feedback_range': list(range(1, 11))}
@staticmethod
def before_next_page(player, timeout_happened):
player.participant.end_time = time.time()
player.participant.finished = True
class Redirect(Page):
@staticmethod
def is_displayed(player):
return player.round_number == C.NUM_ROUNDS
# =============================================================================
# DEV: SKIP-TO-POSTLOOP SUPPORT
# =============================================================================
# When a developer clicks "Skip to post-loop" on the Welcome page,
# all pre-loop and loop pages are skipped. This patches their is_displayed
# methods to also check the skip_loop flag.
def _add_skip_loop_gate(page_cls):
"""Wrap a page's is_displayed to also return False when skip_loop is active."""
original_is_displayed = page_cls.is_displayed
@staticmethod
def is_displayed(player):
try:
if player.participant.skip_loop:
return False
except (KeyError, AttributeError):
pass
return original_is_displayed(player)
page_cls.is_displayed = is_displayed
for _cls in [
Consent, AnnounceApps, TransitionPage,
Application, YourTask, CQs, IntroduceMPL, SuggestConsiderations,
Cases, Cases2, Cases3Explained, Cases3, ReviewStatements,
RedoCases, RedoCases2, RedoCases3Explained, RedoCases3, RedoReviewStatements,
MotivesOpen, IdealsProjection,
]:
_add_skip_loop_gate(_cls)
# =============================================================================
# FOCUS / VISIBILITY TRACKING
# =============================================================================
# Automatically adds _focus_blur_count, _focus_blur_duration, _focus_vis_count,
# _focus_vis_duration to every page's form_fields and accumulates data into
# participant.focus_log (JSON dict keyed by "round_PageName").
_FOCUS_FIELDS = ['_focus_blur_count', '_focus_blur_duration', '_focus_vis_count', '_focus_vis_duration']
def _add_focus_tracking(page_cls):
"""Patch a page class to collect focus/visibility tracking fields."""
# Ensure form_model is set
if not getattr(page_cls, 'form_model', None):
page_cls.form_model = 'player'
# Append tracking fields to form_fields / get_form_fields
orig_get = getattr(page_cls, 'get_form_fields', None)
if orig_get:
orig_fn = orig_get.__func__ if hasattr(orig_get, '__func__') else orig_get
@staticmethod
def get_form_fields(player, _orig=orig_fn):
return _orig(player) + _FOCUS_FIELDS
page_cls.get_form_fields = get_form_fields
else:
existing = list(getattr(page_cls, 'form_fields', []))
if '_focus_blur_count' not in existing:
existing = existing + _FOCUS_FIELDS
page_cls.form_fields = existing
# Wrap before_next_page to accumulate data into participant.focus_log.
# Save the original (own or inherited) before_next_page as _orig_before_next_page
# so we can call it after logging, without re-triggering the focus logging.
page_name = page_cls.__name__
# Get original: prefer own definition, fall back to inherited
if 'before_next_page' in page_cls.__dict__:
orig_bnp = page_cls.__dict__['before_next_page']
else:
# Inherited — get the _unwrapped_ original stored by the parent's decorator
orig_bnp = getattr(page_cls, '_orig_before_next_page', None)
# Store original so subclasses can access it
page_cls._orig_before_next_page = orig_bnp
@staticmethod
def before_next_page(player, timeout_happened, _orig=orig_bnp, _name=page_name):
log = json.loads(player.participant.focus_log or '{}')
key = f"{player.round_number}_{_name}"
log[key] = {
'blur_count': player._focus_blur_count or 0,
'blur_duration': round(player._focus_blur_duration or 0, 1),
'vis_count': player._focus_vis_count or 0,
'vis_duration': round(player._focus_vis_duration or 0, 1),
}
player.participant.focus_log = json.dumps(log)
if _orig:
_orig(player, timeout_happened)
page_cls.before_next_page = before_next_page
# Apply to all page classes except Redirect (which immediately redirects)
for _cls in [
Captcha, Welcome, Consent, AnnounceApps, TransitionPage,
Application, YourTask, CQs, IntroduceMPL, SuggestConsiderations,
Cases, Cases2, Cases3Explained, Cases3, ReviewStatements,
RedoCases, RedoCases2, RedoCases3Explained, RedoCases3, RedoReviewStatements,
MotivesOpen, IdealsProjection, WTPSummary,
MotivesInconsistency, DougsDG, PolicyQuestions, ADStatements, Feedback,
]:
_add_focus_tracking(_cls)
# =============================================================================
# PAGE SEQUENCE
# =============================================================================
page_sequence = [
# Pre-loop (round 1 only)
Captcha,
Welcome,
Consent,
AnnounceApps,
# Loop (every round)
TransitionPage,
Application,
YourTask,
CQs,
IntroduceMPL,
SuggestConsiderations,
Cases,
Cases2,
Cases3Explained,
Cases3,
ReviewStatements,
# Redo pages (shown only if participant chose "No" on ReviewStatements)
RedoCases,
RedoCases2,
RedoCases3Explained,
RedoCases3,
RedoReviewStatements,
MotivesOpen,
IdealsProjection,
# Post-loop (round 6 only)
WTPSummary,
MotivesInconsistency,
DougsDG,
PolicyQuestions,
ADStatements,
Feedback,
Redirect,
]