# APP: oath_follower (Second Mover) # FILE: models.py from otree.api import ( models, widgets, BaseConstants, BaseSubsession, BaseGroup, BasePlayer, ExtraModel, ) import csv, random import time from pathlib import Path author = 'Aidas' doc = """ Oath follower experiment - Wave 2 """ class Constants(BaseConstants): name_in_url = 'wave2' players_per_group = None num_rounds = 1 pounds = 1/20 # Exchange rate: 20 tokens = £1.00 showup_fee = 1.00 # Show-up fee in GBP endowment = 20 def parse_bool(x): """Robust parsing for CSV values: True/False, 1/0, yes/no, etc.""" if isinstance(x, bool): return x s = str(x).strip().lower() if s in ('true', '1', 'yes', 'y', 't'): return True if s in ('false', '0', 'no', 'n', 'f'): return False raise ValueError(f"Could not parse boolean value from: {x!r}") class Subsession(BaseSubsession): def creating_session(self): """ Load first-mover data ONCE into database — all 5 treatments. Database storage prevents race conditions and survives server restarts. """ # Set Prolific_ID from participant.label for all players for p in self.get_players(): p.Prolific_ID = p.participant.label # Check if already initialized to prevent duplicates existing = FirstMoverRecord.filter(subsession=self) if len(list(existing)) > 0: return # Load all 5 treatment CSV files for treatment in ['T1', 'T2', 'T3', 'T4', 'T5']: csv_filename = f'first_movers_{treatment}.csv' csv_path = Path(__file__).resolve().parent / csv_filename if not csv_path.exists(): raise RuntimeError(f"CSV file not found: {csv_filename}") with csv_path.open(newline='', encoding='utf-8-sig') as f: reader = csv.DictReader(f) expected = {'first_mover_id', 'first_contribution', 'take_oath', 'slots'} missing = expected - set(reader.fieldnames or []) if missing: raise RuntimeError(f"CSV {csv_filename} missing columns {missing}.") for row in reader: FirstMoverRecord.create( subsession=self, treatment=treatment, first_mover_id=str(row['first_mover_id']), first_contribution=float(row['first_contribution']), take_oath=parse_bool(row['take_oath']), slots=int(row['slots']), assigned_count=0, ) class Group(BaseGroup): pass class Player(BasePlayer): Prolific_ID = models.StringField(label="Prolific ID") recaptcha_score = models.FloatField(blank=True) recaptcha_token = models.LongStringField(blank=True) completed = models.BooleanField(initial=False) treatment = models.StringField(blank=True) # e.g. 'T1', 'T2', ... first_mover_guess_correct = models.BooleanField(blank=True) # True if guess matched actual contribution oath_recall_correct = models.BooleanField(blank=True) # True if oath recall matched actual (oath treatments only) bonus_tokens = models.IntegerField(initial=0) # total incentive tokens earned control_question = models.StringField( choices=[ ['1', 'I agree to take part. Take me to the study.'], ['2', 'I do not agree to take part in this study. Take me back to Prolific.'], ], label="Do you agree to participate in this study?", widget=widgets.RadioSelect ) # Variables assigned from CSV pool matched_first_mover_id = models.StringField() observed_first_contribution = models.FloatField() observed_take_oath = models.BooleanField() # Comprehension quiz quiz_payoff_a = models.IntegerField( label="What is the total payoff for group member A?", min=0, max=60 ) quiz_payoff_b = models.IntegerField( label="What is the total payoff for group member B?", min=0, max=60 ) quiz_payoff_c = models.IntegerField( label="What is the total payoff for group member C?", min=0, max=60 ) quiz_attempts = models.IntegerField(initial=0) # First mover guess first_mover_guess = models.IntegerField( label="How many tokens do you think the First Mover contributed to the group project (between 0 and 20 tokens)?", min=0, max=20 ) # Contribution decision contribution = models.IntegerField( label="How many tokens do you want to contribute to the group project (between 0 and 20 tokens)?", min=0, max=20 ) # Other second mover guess other_second_mover_guess = models.IntegerField( label="How many tokens do you think the other Second Mover contributed to the group project (between 0 and 20 tokens)?", min=0, max=20 ) # iOS connection questionnaire ios_distance = models.FloatField(blank=True) ios_overlap = models.FloatField(blank=True) ios_number = models.IntegerField(blank=True) # Oath recall — split across two pages oath_taken_recall = models.BooleanField( label="Did the First Mover take an oath?", choices=[[True, 'Yes'], [False, 'No']], widget=widgets.RadioSelect ) oath_recall = models.LongStringField( label="In your own words, what was the oath about?", blank=True ) age_questionnaire = models.IntegerField( label="What is your age?", min=18, max=120 ) gender_questionnaire = models.StringField( label="What is your gender?", choices=[ ['Male', 'Male'], ['Female', 'Female'], ['Non-binary', 'Non-binary / Third gender'], ['Prefer not to say', 'Prefer not to say'] ], widget=widgets.RadioSelect ) political_affiliation = models.StringField( label="Which of the following best describes your political identification?", choices=[ ['Democrat', 'Democrat'], ['Republican', 'Republican'], ['Independent', 'Independent'], ['Other', 'Other'], ['Prefer not to say', 'Prefer not to say'] ], widget=widgets.RadioSelect ) religious_affiliation = models.StringField( label="What is your religious denomination or affiliation?", choices=[ ['Catholic', 'Christian (Roman Catholic)'], ['Protestant', 'Christian (Protestant)'], ['Jewish', 'Jewish'], ['Muslim', 'Muslim'], ['Hindu', 'Hindu'], ['Buddhist', 'Buddhist'], ['Atheist', 'Atheist / Agnostic'], ['Other', 'Other'] ], widget=widgets.RadioSelect ) religious_affiliation_other = models.StringField( label="If Other, please specify:", blank=True ) faith_pray_daily = models.IntegerField( label="I pray daily.", choices=[ [1, '1 = Strongly disagree'], [2, '2 = Disagree'], [3, '3 = Agree'], [4, '4 = Strongly agree'] ], widget=widgets.RadioSelect ) faith_meaning_purpose = models.IntegerField( label="I look to my faith as providing meaning and purpose in my life.", choices=[ [1, '1 = Strongly disagree'], [2, '2 = Disagree'], [3, '3 = Agree'], [4, '4 = Strongly agree'] ], widget=widgets.RadioSelect ) faith_active = models.IntegerField( label="I consider myself active in my faith or church.", choices=[ [1, '1 = Strongly disagree'], [2, '2 = Disagree'], [3, '3 = Agree'], [4, '4 = Strongly agree'] ], widget=widgets.RadioSelect ) faith_enjoy_community = models.IntegerField( label="I enjoy being around others who share my faith.", choices=[ [1, '1 = Strongly disagree'], [2, '2 = Disagree'], [3, '3 = Agree'], [4, '4 = Strongly agree'] ], widget=widgets.RadioSelect ) faith_impacts_decisions = models.IntegerField( label="My faith impacts many of my decisions.", choices=[ [1, '1 = Strongly disagree'], [2, '2 = Disagree'], [3, '3 = Agree'], [4, '4 = Strongly agree'] ], widget=widgets.RadioSelect ) attention_check = models.StringField( label="Based on the text you read before, what is your favourite drink?", choices=[ ['Orange juice', 'Orange juice'], ['Coffee', 'Coffee'], ['Tea', 'Tea'], ['Water', 'Water'], ['Lemonade', 'Lemonade'], ['Cola', 'Cola'], ], widget=widgets.RadioSelect ) additional_comments = models.LongStringField( label="Do you have any additional comments about the experiment?", blank=True ) class FirstMoverRecord(ExtraModel): """ Stores first-mover data AND tracks assignment counts in the database. Persists across server restarts and prevents race conditions. """ subsession = models.Link(Subsession) treatment = models.StringField() first_mover_id = models.StringField() first_contribution = models.FloatField() take_oath = models.BooleanField() slots = models.IntegerField() assigned_count = models.IntegerField(initial=0) class MatchLog(ExtraModel): """Records which second movers are matched with which first movers.""" subsession = models.Link(Subsession) participant_code = models.StringField() prolific_id = models.StringField(blank=True) first_mover_id = models.StringField() first_contribution = models.FloatField() first_took_oath = models.BooleanField() created_ts = models.FloatField() def assign_treatment(player: Player): """ Assign a treatment to the player using slot-aware rotation. Cycles T1→T2→T3→T4→T5→T1... but skips any treatment with no remaining slots. Must be called before assign_first_mover. """ if 'treatment' in player.participant.vars: return # already assigned from settings import OATH_TEXTS treatments = ['T1', 'T2', 'T3', 'T4', 'T5'] all_records = list(FirstMoverRecord.filter(subsession=player.subsession)) # Build set of treatments that still have available slots treatments_with_slots = set( r.treatment for r in all_records if r.assigned_count < r.slots ) if not treatments_with_slots: # All slots exhausted — player will be redirected to StudyFull return # Find the next treatment in rotation that has slots idx = player.session.vars.get('treatment_index', 0) for offset in range(5): candidate = treatments[(idx + offset) % 5] if candidate in treatments_with_slots: treatment = candidate player.session.vars['treatment_index'] = (idx + offset + 1) break else: return # no treatment available player.participant.vars['treatment'] = treatment player.participant.vars['oath_text'] = OATH_TEXTS.get(treatment) or '' def check_slots_available(player: Player): """ Check if there are available slots across ALL treatments. Returns True if any slots available (treatment assigned later). """ all_records = list(FirstMoverRecord.filter(subsession=player.subsession)) if len(all_records) == 0: try: player.subsession.creating_session() all_records = list(FirstMoverRecord.filter(subsession=player.subsession)) except Exception: return False if len(all_records) == 0: return False available = [r for r in all_records if r.assigned_count < r.slots] return len(available) > 0 def assign_first_mover(player: Player): """ Assign a first-mover from the database pool to the current player, filtered to the player's assigned treatment. Concurrent-safe: increments first, then verifies not over-allocated. """ # Assign only once (prevents double-decrement on page refresh) if player.field_maybe_none('matched_first_mover_id'): return treatment = player.participant.vars.get('treatment', '') all_records = list(FirstMoverRecord.filter(subsession=player.subsession)) available = [r for r in all_records if r.assigned_count < r.slots and r.treatment == treatment] if not available: raise RuntimeError( f"No first-mover slots left for treatment {treatment}. All slots are exhausted." ) rec = random.choice(available) # Increment first, then verify we haven't exceeded slots due to race condition rec.assigned_count += 1 if rec.assigned_count > rec.slots: rec.assigned_count -= 1 return assign_first_mover(player) player.matched_first_mover_id = rec.first_mover_id player.observed_first_contribution = rec.first_contribution player.observed_take_oath = rec.take_oath MatchLog.create( subsession=player.subsession, participant_code=player.participant.code, prolific_id=player.field_maybe_none('Prolific_ID') or "", first_mover_id=player.matched_first_mover_id, first_contribution=player.observed_first_contribution, first_took_oath=player.observed_take_oath, created_ts=time.time(), ) def vars_for_admin_report(subsession): """Generate admin report with time-based dropout detection.""" import time as _time DROPOUT_THRESHOLD_MINUTES = 30 records = list(FirstMoverRecord.filter(subsession=subsession)) logs = list(MatchLog.filter(subsession=subsession)) all_players = subsession.get_players() players = {p.participant.code: p for p in all_players} now = _time.time() # Per-first-mover counts completed_by_fm = {} active_by_fm = {} dropped_by_fm = {} for log in logs: fm_id = log.first_mover_id p = players.get(log.participant_code) if not p: continue completed = p.field_maybe_none('completed') or False last_ts = p.participant._last_request_timestamp mins_inactive = (now - last_ts) / 60 if last_ts else 9999 if completed: completed_by_fm[fm_id] = completed_by_fm.get(fm_id, 0) + 1 elif mins_inactive < DROPOUT_THRESHOLD_MINUTES: active_by_fm[fm_id] = active_by_fm.get(fm_id, 0) + 1 else: dropped_by_fm[fm_id] = dropped_by_fm.get(fm_id, 0) + 1 # Participants with treatment assigned but not yet matched to a first mover pre_assign_active = {} pre_assign_dropped = {} never_started = 0 for p in all_players: if p.field_maybe_none('matched_first_mover_id'): continue last_ts = p.participant._last_request_timestamp mins_inactive = (now - last_ts) / 60 if last_ts else 9999 t = p.field_maybe_none('treatment') if not t: never_started += 1 elif mins_inactive < DROPOUT_THRESHOLD_MINUTES: pre_assign_active[t] = pre_assign_active.get(t, 0) + 1 else: pre_assign_dropped[t] = pre_assign_dropped.get(t, 0) + 1 rows = [] total_slots = 0 total_completed = 0 for rec in records: fm_id = rec.first_mover_id completed = completed_by_fm.get(fm_id, 0) rows.append(dict( treatment=rec.treatment, first_mover_id=fm_id, first_contribution=rec.first_contribution, took_oath=rec.take_oath, slots=rec.slots, assigned=rec.assigned_count, completed=completed, likely_active=active_by_fm.get(fm_id, 0), likely_dropped=dropped_by_fm.get(fm_id, 0), remaining_slots=max(0, rec.slots - rec.assigned_count), needed_for_topup=max(0, rec.slots - completed), needs_topup=completed < rec.slots, )) total_slots += rec.slots total_completed += completed rows.sort(key=lambda x: (x['treatment'], str(x['first_mover_id']))) slots_filled = sum(rec.assigned_count for rec in records) total_active = sum(active_by_fm.values()) total_dropped = sum(dropped_by_fm.values()) percent_completed = round((total_completed / total_slots) * 100) if total_slots > 0 else 0 treatment_summaries = [] for t in ['T1', 'T2', 'T3', 'T4', 'T5']: t_rows = [r for r in rows if r['treatment'] == t] t_slots = sum(r['slots'] for r in t_rows) t_completed = sum(r['completed'] for r in t_rows) t_assigned = sum(r['assigned'] for r in t_rows) t_active = sum(r['likely_active'] for r in t_rows) + pre_assign_active.get(t, 0) t_dropped = sum(r['likely_dropped'] for r in t_rows) + pre_assign_dropped.get(t, 0) treatment_summaries.append(dict( name=t, total_slots=t_slots, assigned=t_assigned, completed=t_completed, likely_active=t_active, likely_dropped=t_dropped, remaining=max(0, t_slots - t_assigned), topup_needed=max(0, t_slots - t_completed), )) return dict( total_participants=len(all_players), total_completed=total_completed, total_active=total_active, total_dropped=total_dropped, total_never_started=never_started, total_first_movers=len(records), total_slots=total_slots, slots_filled=slots_filled, slots_remaining=max(0, total_slots - slots_filled), percent_completed=percent_completed, topup_needed=sum(1 for r in rows if r['needs_topup']), dropout_threshold=DROPOUT_THRESHOLD_MINUTES, treatments=treatment_summaries, rows=rows, )