from otree.api import * import random import time import timer_utils import json from datetime import datetime, date, timedelta doc = """ """ class C(BaseConstants): NAME_IN_URL = 'intro' PLAYERS_PER_GROUP = None NUM_ROUNDS = 1 CATEGORIES = [ "My income", "Partner’s income", "My expense", "Common expense", ] class Subsession(BaseSubsession): pass class Group(BaseGroup): pass class Player(BasePlayer): pay_items = models.LongStringField( initial='[]', doc="JSON list of payment/expense items" ) Share_finances = models.StringField( label='Do you and your partner share finances?', choices=[['Full', "Yes, fully shared (most money pooled, joint bills/accounts)"], ['Partly', "Yes, partly shared (some joint bills/accounts, some separate)"], ['No', "No, we keep finances separate"], ['NoPartner', "I don’t have a partner"], ], widget=widgets.RadioSelect, blank=False, ) Partner_income_importance = models.StringField( default='Bos', label="How important is your partner’s salary or benefit income for covering your household expenses?", choices=[['Very', 'Very important - it is a main part of our household budget'], ['Somewhat', 'Somewhat important - it helps but is not the main source'], ['Not', 'Not important - it does not affect our household budget at all'], ], widget=widgets.RadioSelect ) Know_dates = models.StringField( label='', choices=[['Yes', 'Yes, I know the dates'], ['Maybe', 'I don’t know the dates, but I could easily look them up'], ['No', 'No, I don’t know the dates'], ], widget=widgets.RadioSelect) # Consent consent = models.StringField( label='I have read and agree to the terms above. I consent to the processing of my personal data for this study and to the reuse of my anonymized data for future scientific research.', choices=['Yes, I agree', 'No, I don’t agree'], widget=widgets.RadioSelectHorizontal ) treatment = models.StringField() # Rich_date = models.StringField() # Poor_date = models.StringField() # Poor_first = models.BooleanField() # camera = models.StringField( # label='I have camera (webcam or phone), a pen or pencil, and some paper ready to use for this study.', # choices=['I have them ready.', 'I don’t have them.'], # widget=widgets.RadioSelectHorizontal # ) # camera = models.StringField( # label='I have camera (webcam or phone), a pen or pencil, and some paper ready to use for this study.', # choices=['I have them ready.', 'I don’t have them.'], # widget=widgets.RadioSelectHorizontal # ) # picture_code = models.StringField() # tps1 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # tps2 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # reverse-code in analysis # tps3 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # tps4 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # tps5 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # tps6 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # tps7 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # tps8 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # tps9 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # tps10 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # tps11 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # tps12 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # reverse-code in analysis # tps13 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # tps14 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # tps15 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # tps16 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # tps17 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # tps18 = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # tpstotal= models.FloatField() # attention = models.IntegerField(choices=[1, 2, 3, 4, 5, 6, 7]) # ----------------------- class Consent(Page): form_model = 'player' form_fields = ['consent'] @staticmethod def vars_for_template(player): participant = player.participant return dict( fee=participant.session.config['participation_fee'], time=participant.session.config['time'], token_rate = int(1 / participant.session.config['real_world_currency_per_point']), ) def is_displayed(player): return player.session.config['who'] == 'dictator' def before_next_page(player, timeout_happened): # START THE GLOBAL TIMER HERE (Receivers) timer_utils.start_timer(player.participant) # player.Poor_first = random.choice([True, False]) player.participant.vars['rich_poor_treatment'] = "" def error_message(self, values): if values.get('consent') == "No, I don’t agree": return 'You cannot participate to this study unless you consent to these conditions!' class Share(Page): form_model = 'player' form_fields = ['Share_finances'] class Income_Importance(Page): form_model = 'player' form_fields = ['Partner_income_importance'] @staticmethod def is_displayed(player: Player): return player.Share_finances in ['Partly', 'Full'] class Know_dates(Page): form_model = 'player' form_fields = ['Know_dates'] class Screenout0(Page): # Shows if validation fails OR if they time out early # @staticmethod # def vars_for_template(player): # participant = player.participant # return dict( # link1=participant.session.config['smallpaylink']) @staticmethod def is_displayed(player: Player): # Fall-through logic: if time is up, show this screenout/timeout page return (player.Know_dates == 'No') class Screenout(Page): # Shows if validation fails OR if they time out early @staticmethod def vars_for_template(player): participant = player.participant return dict( link1=participant.session.config['smallpaylink']) @staticmethod def is_displayed(player: Player): # Using .get() is safer in case the var hasn't been set yet return player.participant.vars.get('rich_poor_treatment') == 'None' def get_eligibility(player, items, today): # Optional trial override if player.session.config.get('trail') == 2: return random.choice(['Rich', 'Poor']) INCOME_CATS = {"My income", "Partner’s income"} MIN_AMOUNT = 400 MAX_UNIQUE_PAYDAYS = 3 # 4 or more => None def amount_ok(x): try: return float(x) >= MIN_AMOUNT except (TypeError, ValueError): return False # Qualifying payday dates only paydays = sorted({ item['date'] for item in items if item.get('category') in INCOME_CATS and item.get('date') and amount_ok(item.get('amount')) }) # Exclusions if not paydays: return 'None' if len(paydays) > MAX_UNIQUE_PAYDAYS: return 'None' # Key dates prev_payday = max((d for d in paydays if d < today), default=None) # strictly before today next_payday = min((d for d in paydays if d > today), default=None) # strictly after today last_on_or_before = max((d for d in paydays if d <= today), default=None) # includes today # ----------------- # POOR # ----------------- poor = False # Case A: prev + next, long enough gap, closer to next, at least 2 days before next if prev_payday is not None and next_payday is not None: gap = (next_payday - prev_payday).days days_since_prev = (today - prev_payday).days days_until_next = (next_payday - today).days poor = ( gap >= 10 and days_until_next < days_since_prev and days_until_next >= 2 ) # Case B: no prev, next is in 2 to 7 days elif prev_payday is None and next_payday is not None: days_until_next = (next_payday - today).days poor = 2 <= days_until_next <= 7 # ----------------- # RICH # ----------------- rich = False # Case A: prev + next, long enough gap, closer to prev if prev_payday is not None and next_payday is not None: gap = (next_payday - prev_payday).days days_since_prev = (today - prev_payday).days days_until_next = (next_payday - today).days if gap >= 10 and days_since_prev < days_until_next: rich = True # Case B: no next, most recent payday on/before today was in 0 to 7 days if not rich and next_payday is None and last_on_or_before is not None: days_since_last = (today - last_on_or_before).days if 0 <= days_since_last <= 7: rich = True # Case C: prev + next, payday dates are very close together (5 days or less) if not rich and prev_payday is not None and next_payday is not None: gap = (next_payday - prev_payday).days if gap <= 5: rich = True # Final assignment if poor: return 'Poor' if rich: return 'Rich' return 'None' class PaySchedule(Page): form_model = 'player' form_fields = ['pay_items'] timer_text = "Time remaining:" def get_timeout_seconds(player): rem = timer_utils.get_remaining_time(player.participant) if rem < timer_utils.WARNING_SECONDS: return rem def is_displayed(player): return not timer_utils.is_time_up(player.participant) @staticmethod def vars_for_template(player: Player): try: items = json.loads(player.pay_items or "[]") except Exception: items = [] return { 'pay_items_json': json.dumps(items), 'categories': C.CATEGORIES } @staticmethod def before_next_page(player: Player, timeout_happened): try: # 1. CLEAN AND SAVE DATA (Preserves your original cleaning logic) raw_items = json.loads(player.pay_items or "[]") out = [] clean_for_logic = [] for it in raw_items: date_str = it.get('date') cat = it.get('category') amt = it.get('amount') if date_str and cat and (amt is not None): try: f = float(amt) if f > 0: out.append({'date': date_str, 'category': cat, 'amount': f}) dt = datetime.strptime(date_str, '%Y-%m-%d').date() clean_for_logic.append({'date': dt, 'category': cat, 'amount': f}) else: pass except Exception: pass player.pay_items = json.dumps(out) # 2. RUN ELIGIBILITY (Logic now handles the 'trail' check) player.participant.vars['rich_poor_treatment'] = get_eligibility(player, clean_for_logic, date.today()) player.treatment = player.participant.vars['rich_poor_treatment'] except Exception as e: # Fallback to prevent crash if JSON is malformed player.pay_items = '[]' player.participant.vars['rich_poor_treatment'] = 'None' @staticmethod def error_message(player, values): raw = values.get('pay_items') if raw is None: raw = player.pay_items # Changed self.player to player try: items = json.loads(raw or "[]") except Exception: items = [] if len(items) < 1: return "You must add at least 1 entry to proceed." # --- Helper Logic (Put this outside the class or in a utils file) --- # class Expense_poor1(Page): # form_model = 'player' # form_fields = ['Poor_date'] # timer_text = "Time remaining:" # # def get_timeout_seconds(player): # rem = timer_utils.get_remaining_time(player.participant) # if rem < timer_utils.WARNING_SECONDS: # return rem # # # Skip page if global time is up # def is_displayed(player): # return not timer_utils.is_time_up(player.participant) # # @staticmethod # def vars_for_template(player: Player): # try: # items = json.loads(player.pay_items or "[]") # except Exception: # items = [] # # return { # 'pay_items_json': json.dumps(items), # 'categories': C.CATEGORIES, # 'rich_date_json': json.dumps(player.field_maybe_none('Rich_date') or ""), # } # # def is_displayed(player: Player): # return player.Poor_first == True # class Expense_rich(Page): # form_model = 'player' # form_fields = ['Rich_date'] # timer_text = "This study is expected to take 5 minutes. You have a maximum of 10 minutes. Time remaining:" # # timer_text = "Time remaining:" # # def get_timeout_seconds(player): # rem = timer_utils.get_remaining_time(player.participant) # if rem < timer_utils.WARNING_SECONDS: # return rem # # # Skip page if global time is up # def is_displayed(player): # return not timer_utils.is_time_up(player.participant) # # @staticmethod # def vars_for_template(player: Player): # try: # items = json.loads(player.pay_items or "[]") # except Exception: # items = [] # # return { # 'pay_items_json': json.dumps(items), # 'categories': C.CATEGORIES, # 'poor_date_json': json.dumps(player.field_maybe_none('Poor_date') or "") # } # # def before_next_page(player: Player, timeout_happened): # participant = player.participant # # num = random.uniform(100, 999) # first part # # dec = random.randint(100, 999) # second part # # player.picture_code = f"{int(num)}.{dec}" # participant.finished = True # class Expense_poor2(Page): # form_model = 'player' # form_fields = ['Poor_date'] # timer_text = "Time remaining:" # # def get_timeout_seconds(player): # rem = timer_utils.get_remaining_time(player.participant) # if rem < timer_utils.WARNING_SECONDS: # return rem # # Skip page if global time is up # def is_displayed(player): # return not timer_utils.is_time_up(player.participant) # # @staticmethod # def vars_for_template(player: Player): # try: # items = json.loads(player.pay_items or "[]") # except Exception: # items = [] # # return { # 'pay_items_json': json.dumps(items), # 'categories': C.CATEGORIES, # 'rich_date_json': json.dumps(player.Rich_date or ""), # } # # def is_displayed(player: Player): # return player.Poor_first == False # class Image(Page): # form_model = 'player' # form_fields = ['entered_code'] # # @staticmethod # def vars_for_template(player: Player): # return dict(code=player.picture_code) # # def error_message(player, values): # if values['entered_code'].upper().strip() not in VALID_CODES: # return "The confirmation code is incorrect. Please check and try again." # class Transphobia1(Page): # form_model = 'player' # form_fields = [f'tps{i}' for i in range(1, 10)] # class Transphobia2(Page): # form_model = 'player' # form_fields = [f'tps{i}' for i in range(10, 19)] + ['attention'] # @staticmethod # def before_next_page(player: Player, timeout_happened): # # calculate total score # reverse_coded = [3, 4, 5,6, 8,9,11,12,13, 14] # total = 0 # for i in range(1, 19): # val = getattr(player, f'tps{i}') # if i in reverse_coded: # val = 8 - val # total += val # player.tpstotal = total / 18.0 page_sequence = [ Consent, Know_dates, Screenout0, Share, Income_Importance, PaySchedule, Screenout]