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, ]