# APP: oathexp (First Mover) # FILE: pages.py from otree.api import * from otree import settings as otree_settings from settings import OATH_TEXTS from difflib import SequenceMatcher import os, urllib.request, urllib.parse, json def oath_text(player): """Return the oath text for this treatment (empty string = no oath).""" return player.participant.vars.get('oath_text') or '' def has_oath(player): """True if this treatment has an oath.""" return bool(oath_text(player)) def base_vars(player): """Return oath_text for every page that includes instructions.html.""" return dict(oath_text=oath_text(player)) def consented(player): """Display guard: only show to participants who agreed to take part.""" return player.field_maybe_none('control_question') == '1' class Welcome(Page): form_model = 'player' form_fields = ['control_question', 'recaptcha_token'] @staticmethod def vars_for_template(player): return dict(showup_fee=otree_settings.SHOWUP_FEE) @staticmethod def error_message(player, values): if values['control_question'] != '1': return "If you do not agree to take part in this study, please close your browser tab." @staticmethod def before_next_page(player, timeout_happened=False): token = player.field_maybe_none('recaptcha_token') or '' if not token: return secret = os.environ.get('RECAPTCHA_SECRET_KEY', '') if not secret: return data = urllib.parse.urlencode({'secret': secret, 'response': token}).encode() try: req = urllib.request.urlopen( 'https://www.google.com/recaptcha/api/siteverify', data=data, timeout=5 ) result = json.loads(req.read()) player.recaptcha_score = result.get('score') except Exception: player.recaptcha_score = None class EndStudy(Page): """Shown to participants who declined to participate.""" @staticmethod def is_displayed(player): return player.field_maybe_none('control_question') == '2' class ProlificID(Page): @staticmethod def is_displayed(player): return consented(player) @staticmethod def vars_for_template(player): # Ensure Prolific_ID is set from participant.label (set when participant clicks link) if player.participant.label: player.Prolific_ID = player.participant.label return dict(prolific_id=player.field_maybe_none('Prolific_ID') or 'Not set') class General_Instructions(Page): @staticmethod def is_displayed(player): return consented(player) @staticmethod def before_next_page(player, timeout_happened=False): # Assign treatment here so Description page renders with correct oath text if 'treatment' not in player.participant.vars: force = player.session.config.get('force_treatment') if force: # Top-up session: all participants get the specified treatment treatment = force else: # Main session: rotating sequential assignment treatments = ['T1', 'T2', 'T3', 'T4', 'T5'] idx = player.session.vars.get('treatment_index', 0) treatment = treatments[idx % 5] player.session.vars['treatment_index'] = idx + 1 player.participant.vars['treatment'] = treatment player.participant.vars['oath_text'] = OATH_TEXTS.get(treatment) or '' class Description(Page): @staticmethod def is_displayed(player): return consented(player) @staticmethod def vars_for_template(player): return base_vars(player) @staticmethod def before_next_page(player, timeout_happened=False): # Treatment already set in General_Instructions — just save to player field player.treatment = player.participant.vars.get('treatment', '') class ComprehensionQuiz(Page): form_model = 'player' form_fields = ['quiz_payoff_a', 'quiz_payoff_b', 'quiz_payoff_c'] @staticmethod def is_displayed(player): return consented(player) @staticmethod def error_message(player, values): player.quiz_attempts += 1 correct = {'quiz_payoff_a': 30, 'quiz_payoff_b': 25, 'quiz_payoff_c': 20} explanations = { 'quiz_payoff_a': "Member A contributes 5, keeps 15. Total contributions = 30, so project share = 0.5 × 30 = 15. Payoff = 15 + 15 = 30.", 'quiz_payoff_b': "Member B contributes 10, keeps 10. Project share = 15. Payoff = 10 + 15 = 25.", 'quiz_payoff_c': "Member C contributes 15, keeps 5. Project share = 15. Payoff = 5 + 15 = 20.", } errors = {} all_correct = True for field, answer in correct.items(): if values[field] != answer: all_correct = False errors[field] = explanations[field] if all_correct: return None player.participant.vars['quiz_error_a'] = errors.get('quiz_payoff_a', '') player.participant.vars['quiz_error_b'] = errors.get('quiz_payoff_b', '') player.participant.vars['quiz_error_c'] = errors.get('quiz_payoff_c', '') return "One or more answers are incorrect. Please review the explanations below and try again." @staticmethod def vars_for_template(player): return dict( attempt_number=player.quiz_attempts + 1, oath_text=oath_text(player), quiz_error_a=player.participant.vars.get('quiz_error_a', ''), quiz_error_b=player.participant.vars.get('quiz_error_b', ''), quiz_error_c=player.participant.vars.get('quiz_error_c', ''), ) class Oath(Page): form_model = 'player' form_fields = ['take_oath'] @staticmethod def is_displayed(player): return consented(player) and has_oath(player) @staticmethod def vars_for_template(player): return base_vars(player) def normalize(s: str) -> str: return " ".join(s.strip().lower().split()) class Oath_Take(Page): form_model = 'player' form_fields = ['typed_oath', 'typed_initials'] @staticmethod def is_displayed(player): return has_oath(player) and bool(player.field_maybe_none('take_oath')) @staticmethod def vars_for_template(player): return base_vars(player) @staticmethod def error_message(player, values): typed = normalize(values['typed_oath']) target = normalize(oath_text(player)) score = SequenceMatcher(None, typed, target).ratio() if score < 0.95: return "Your typed oath doesn't match closely enough. Please type it exactly as shown." class Contributions(Page): form_model = 'player' form_fields = ['contribution'] @staticmethod def is_displayed(player): return consented(player) @staticmethod def vars_for_template(player): return base_vars(player) class Beliefs(Page): form_model = 'player' form_fields = ['leader_belief_1', 'leader_belief_2'] @staticmethod def is_displayed(player): return consented(player) @staticmethod def vars_for_template(player): return base_vars(player) class Questionnaire(Page): """Demographics + IOS connection to Second Movers.""" form_model = 'player' @staticmethod def is_displayed(player): return consented(player) @staticmethod def get_form_fields(player): fields = ['ios_distance', 'ios_overlap'] ios_type = player.session.config.get('ios_type', 'original') if ios_type != 'continuous': fields.append('ios_number') if player.field_maybe_none('take_oath'): fields.append('oath_recall') fields.extend([ 'age_questionnaire', 'gender_questionnaire', 'political_affiliation', 'religious_affiliation', 'religious_affiliation_other', ]) return fields @staticmethod def vars_for_template(player): ios_type = player.session.config.get('ios_type', 'original') return dict( took_oath=bool(player.field_maybe_none('take_oath')), ios_type_value=ios_type, is_ios_continuous=(ios_type == 'continuous'), is_ios_step_choice=(ios_type == 'step-choice'), is_ios_original=(ios_type == 'original'), ) class FaithQuestionnaire(Page): """Part 2 — faith scale.""" form_model = 'player' @staticmethod def is_displayed(player): return consented(player) @staticmethod def before_next_page(player, timeout_happened=False): player.completed = True form_fields = [ 'faith_pray_daily', 'faith_meaning_purpose', 'faith_active', 'faith_enjoy_community', 'faith_impacts_decisions', 'attention_check', 'additional_comments', ] class Thanks(Page): @staticmethod def is_displayed(player): return consented(player) @staticmethod def vars_for_template(player): return dict(showup_fee=otree_settings.SHOWUP_FEE) @staticmethod def before_next_page(player, timeout_happened=False): # Bonus tokens: leader beliefs are checked post-hoc (need actual SM contributions) # so bonus_tokens stays 0 for leaders — calculated offline after matching pass page_sequence = [ Welcome, EndStudy, ProlificID, General_Instructions, Description, ComprehensionQuiz, Oath, Oath_Take, Contributions, Beliefs, Questionnaire, FaithQuestionnaire, Thanks, ]