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