from otree.api import * doc = """ Post-experiment questionnaire (Screens 33–41). """ def creating_session(subsession): # 不要在这里设置角色! # 角色分配由 consent_intro/RoleAssignmentWait 处理 pass class C(BaseConstants): NAME_IN_URL = 'post_experiment' PLAYERS_PER_GROUP = None NUM_ROUNDS = 1 Conversion_GBP = 1 class Subsession(BaseSubsession): pass class Group(BaseGroup): pass class Player(BasePlayer): # ---------- Likert 1–7 helper via choices ---------- # Worker – PEQ Part 1 (Screen 34) EffortSensitivity_Manager = models.IntegerField( choices=[1, 2, 3, 4, 5, 6, 7], widget=widgets.RadioSelectHorizontal, label=( "To what extent do you think the effort you put into the decode task is affected by the amount of fixed wage" " paid by the Restaurant? " "(1 = Not at all, 7 = Very much)" ), ) EffortSensitivity_Customer = models.IntegerField( choices=[1, 2, 3, 4, 5, 6, 7], widget=widgets.RadioSelectHorizontal, label=( "To what extent do you think the effort you put into the decode task is " "affected by the amount of service charge paid by the Customer? " "(1 = Not at all, 7 = Very much)" ), blank=True, ) MentalAccount = models.IntegerField( choices=[1, 2, 3, 4, 5, 6, 7], widget=widgets.RadioSelectHorizontal, label=( "To what extent do you think the service charge paid by the Customer is different from the " "fixed wage paid by the Restaurant? " "(1 = Not at all, 7 = Very much)" ), blank=True, ) Observability_Manager = models.IntegerField( choices=[1, 2, 3, 4, 5, 6, 7], widget=widgets.RadioSelectHorizontal, label=( "To what extent do you think the restaurant can observe the quality of your service " "(i.e., the effort you put into the decode task)? " "(1 = Not at all, 7 = Very much)" ), ) Observability_Customer = models.IntegerField( choices=[1, 2, 3, 4, 5, 6, 7], widget=widgets.RadioSelectHorizontal, label=( "To what extent do you think the Customer can observe the quality of your service " "(i.e., the effort you put into the decode task)? " "(1 = Not at all, 7 = Very much)" ), ) # Worker – PEQ Part 2 (Screen 35) Controllability1 = models.IntegerField( choices=[1, 2, 3, 4, 5, 6, 7], widget=widgets.RadioSelectHorizontal, label=( "To what extent do you think your total compensation was affected by the quality of your service " "(i.e., the effort you put into the decode task)? " "(1 = Not at all, 7 = Very much)" ), ) Controllability2 = models.IntegerField( choices=[1, 2, 3, 4, 5, 6, 7], widget=widgets.RadioSelectHorizontal, label=( "To what extent do you think you could influence your total compensation?" "(1 = Not at all, 7 = Very much)" ), ) # These 3 appear for Workers under Pre-tip / Post-tip TipReason_Effort_Worker = models.IntegerField( choices=[1, 2, 3, 4, 5, 6, 7], widget=widgets.RadioSelectHorizontal, label=( "To what extent do you perceive that the tip you received from the Customer was affected by the quality of " "your service (i.e., the effort you put into the decode task)? " "(1 = Not at all, 7 = Very much)" ), blank=True, ) TipReason_SocialImage_Worker = models.IntegerField( choices=[1, 2, 3, 4, 5, 6, 7], widget=widgets.RadioSelectHorizontal, label=( "To what do you perceive that the tip you received from the Customer was affected by the social pressure to tip? " "(1 = Not at all, 7 = Very much)" ), blank=True, ) TipReason_SocialNorm_Worker = models.IntegerField( choices=[1, 2, 3, 4, 5, 6, 7], widget=widgets.RadioSelectHorizontal, label=( "To what extent do you perceive that the tip you received from the Customer was affected by how they expect" "other Customers normally tip Workers? " "(1 = Not at all, 7 = Very much)" ), blank=True, ) # Customer – PEQ Part 3 (Screen 36) (Pre-/Post-tip only) TipReason_Effort_Customer = models.IntegerField( choices=[1, 2, 3, 4, 5, 6, 7], widget=widgets.RadioSelectHorizontal, label=( "To what extent was the tip you chose to pay the Worker affected by the quality of their service (i.e., " "the number of decodes they completed)? " "(1 = Not at all, 7 = Very much)" ), blank=True, ) TipReason_SocialImage_Customer = models.IntegerField( choices=[1, 2, 3, 4, 5, 6, 7], widget=widgets.RadioSelectHorizontal, label=( "To what extent was the tip you chose to pay the Worker affected by the social pressure to tip? " "(1 = Not at all, 7 = Very much)" ), blank=True, ) TipReason_SocialNorm_Customer = models.IntegerField( choices=[1, 2, 3, 4, 5, 6, 7], widget=widgets.RadioSelectHorizontal, label=( "To what extent was the tip you chose to pay the Worker affected by how you expect other Customers normally tip Workers?" "(1 = Not at all, 7 = Very much)" ), blank=True, ) # Open-ended Effort_OpenEnded = models.LongStringField( label=( "Given your experience today, please describe the reasons why you chose the level of service quality " "you provided to the Customer (i.e., the level of effort you put into the decode task). " ), blank=True, ) Tip_OpenEnded = models.LongStringField( label=( "Given your experience today, please describe the reasons why you chose the amount of tips to pay the Worker? " ), blank=True, ) # ---------- Work demographics (Screens 39–40) ---------- EstablishmentType = models.StringField( choices=[ "Full-service restaurant", "Limited-service restaurant (e.g., coffee shops, fast food restaurants, take-out restaurants)", "Drinking places (e.g., bars, brasseries, cocktail lounges, nightclubs)", "Special food services (e.g., caterers, mobile food services)", "Other", ], label="What is the type of establishment?", widget=widgets.RadioSelect, blank=True, ) EstablishmentType_other = models.StringField( label="If 'Other', please specify:", blank=True, ) NumberEmployees = models.StringField( choices=[ "Less than 10 employees", "10-20 employees", "21-50 employees", "51-100 employees", "More than 100 employees", ], label="How many employees work at the establishment location?", widget=widgets.RadioSelect, blank=True, ) AttnCheck2 = models.StringField( choices=[("True", "True"), ("False", "False")], label="Please answer 'True' to this question.", widget=widgets.RadioSelect, ) Location = models.StringField( choices=["Canada", "USA"], label="Where is the establishment located?", widget=widgets.RadioSelect, blank=True, ) Chain = models.StringField( choices=[("Yes", "Yes"), ("No", "No")], label="Is the establishment where you work part of a chain of locations?", widget=widgets.RadioSelect, blank=True, ) ChainNumber = models.StringField( choices=["2–5", "6–10", "11–20", "21–50", "More than 50"], label="How many establishment chain locations are there?", widget=widgets.RadioSelect, blank=True, ) # ---------- Work role (Screen 40) ---------- Title = models.StringField( choices=["Server", "Bartender", "Host", "Other"], label="What is/was your job?", widget=widgets.RadioSelect, blank=True, ) Title_other = models.StringField( label="If 'Other', please specify:", blank=True, ) MonthsCurrent = models.IntegerField( label="How many full-time and part-time months have you worked at this job?", min=0, blank=True, ) WeeklyHours = models.IntegerField( label="On average, how many hours do/did you work at this job per week?", min=0, blank=True, ) Compensation = models.StringField( choices=[ ('Fixed', "You are paid an hourly fixed wage. Customers are not permitted to tip."), ('Service Charge', "You are paid an hourly fixed wage and guests pay a service charge on each check, which you receive as additional pay. Customers are not permitted to tip."), ('Pre-tip', "You are paid an hourly fixed wage and guests pay you tips for your service, which you receive as additional pay. Customers tip at the beginning of their visit before you serve them."), ('Post-Tip', "You are paid an hourly fixed wage and guests pay you tips for your service, which you receive as additional pay. Customers tip at the end of their visit after you serve them."), ], label="Which of the below most closely describes how you are/were compensated at this job?", widget=widgets.RadioSelect, blank=True, ) # ---------- Personal demographics (Screen 41) ---------- Gender = models.StringField( choices=[ "Male", "Female", "Non-binary", "I prefer to self-define", "I prefer not to say", ], widget=widgets.RadioSelect, label="Please indicate your gender:", blank=True, ) Gender_self = models.StringField( label="If you prefer to self-define, please specify:", blank=True, ) Age = models.IntegerField( label="Please indicate your age (in years):", min=0, blank=True, ) WorkExperience = models.IntegerField( label="Please indicate the number of years of your full-time work experience:", min=0, blank=True, ) FoodIndustry = models.IntegerField( label=( "Please indicate the number of years you have worked " "(either part-time or full-time) in the food industry:" ), min=0, blank=True, ) # ============================================================ # PAGES # ============================================================ # SCREEN 33 – POST-EXPERIMENT QUESTIONNAIRE INTRO class Screen33_PostExperimentIntro(Page): @staticmethod def is_displayed(player: Player): # Everyone who finished the main experiment return not player.participant.vars.get('exit_early', False) # SCREEN 34 – PEQ PART 1 (Workers only) class Screen34_PEQ_Worker_Part1(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): return player.participant.vars.get('role') == 'Worker' @staticmethod def get_form_fields(player: Player): comp = player.session.config.get('compensation_type') # default fields fields = [ 'EffortSensitivity_Manager', 'Observability_Manager', 'Observability_Customer', ] if comp in ['Pre_tip', 'Post_tip', 'Service_charge']: fields.insert(1, 'EffortSensitivity_Customer') # MentalAccount only if Service_charge if comp == 'Service_charge': fields.insert(2, 'MentalAccount') return fields @staticmethod def error_message(player: Player, values): missing = [name for name, val in values.items() if val in [None, '']] if missing: return "Please answer all questions on this page before continuing." # SCREEN 35 – PEQ PART 2 (Workers only) class Screen35_PEQ_Worker_Part2(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): return player.participant.vars.get('role') == 'Worker' @staticmethod def get_form_fields(player: Player): comp = player.session.config.get('compensation_type') fields = ['Controllability1', 'Controllability2'] if comp in ['Pre_tip', 'Post_tip']: fields += [ 'TipReason_Effort_Worker', 'TipReason_SocialImage_Worker', 'TipReason_SocialNorm_Worker', ] return fields @staticmethod def error_message(player: Player, values): missing = [name for name, val in values.items() if val in [None, '']] if missing: return "Please answer all questions on this page before continuing." # SCREEN 36 – PEQ PART 3 (Customers, Pre-/Post-tip only) class Screen36_PEQ_Customer_Part3(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): role_ok = player.participant.vars.get('role') == 'Customer' comp = player.session.config.get('compensation_type') return role_ok and comp in ['Pre_tip', 'Post_tip'] @staticmethod def get_form_fields(player: Player): return [ 'TipReason_Effort_Customer', 'TipReason_SocialImage_Customer', 'TipReason_SocialNorm_Customer', ] @staticmethod def error_message(player: Player, values): missing = [name for name, val in values.items() if val in [None, '']] if missing: return "Please answer all questions on this page before continuing." # SCREEN 37 – PEQ PART 4 (Workers, open-ended effort reasons) class Screen37_PEQ_Worker_Part4(Page): form_model = 'player' form_fields = ['Effort_OpenEnded'] @staticmethod def is_displayed(player: Player): return player.participant.vars.get('role') == 'Worker' @staticmethod def error_message(player: Player, values): text = (values.get('Effort_OpenEnded') or '').strip() if not text: return "Please provide a response before continuing." # SCREEN 38 – PEQ PART 5 (Customers, Pre-/Post-tip, open-ended tip reasons) class Screen38_PEQ_Customer_Part5(Page): form_model = 'player' form_fields = ['Tip_OpenEnded'] @staticmethod def is_displayed(player: Player): role_ok = player.participant.vars.get('role') == 'Customer' comp = player.session.config.get('compensation_type') return role_ok and comp in ['Pre_tip', 'Post_tip'] @staticmethod def error_message(player: Player, values): text = (values.get('Tip_OpenEnded') or '').strip() if not text: return "Please provide a response before continuing." # SCREEN 39 – WORK DEMOGRAPHICS 1 class Screen39_WorkDemographics1(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): return player.participant.vars.get('role') == 'Worker' form_fields = [ 'EstablishmentType', 'EstablishmentType_other', 'NumberEmployees', 'AttnCheck2', 'Location', 'Chain', 'ChainNumber', ] @staticmethod def error_message(player: Player, values): errors = [] # 2) Required base fields (cannot be blank) required_base = ['EstablishmentType', 'NumberEmployees', 'Location', 'Chain'] missing_base = [f for f in required_base if not values.get(f)] if missing_base: errors.append("Please answer all questions on this page before continuing.") # 3) If 'Other' establishment type → text box required if values.get('EstablishmentType') == 'Other': other_text = (values.get('EstablishmentType_other') or '').strip() if not other_text: errors.append("Please specify the type of establishment when selecting 'Other'.") # 4) If Chain = Yes → ChainNumber required if values.get('Chain') == 'Yes' and not values.get('ChainNumber'): errors.append("Please indicate how many establishment chain locations there are.") if errors: # Combine into one message (oTree shows it at the top) return " ".join(errors) # SCREEN 40 – WORK DEMOGRAPHICS 2 class Screen40_WorkDemographics2(Page): form_model = 'player' @staticmethod def is_displayed(player: Player): return player.participant.vars.get('role') == 'Worker' form_fields = [ 'Title', 'Title_other', 'MonthsCurrent', 'WeeklyHours', 'Compensation', ] @staticmethod def error_message(player: Player, values): errors = [] # 1) Required fields (cannot be blank) required = ['Title', 'MonthsCurrent', 'WeeklyHours', 'Compensation'] missing = [f for f in required if values.get(f) in [None, '']] if missing: errors.append("Please answer all questions on this page before continuing.") # 2) If Title = Other -> Title_other required if values.get('Title') == 'Other': other_text = (values.get('Title_other') or '').strip() if not other_text: errors.append("Please specify your job title when selecting 'Other'.") # 3) Extra sanity check: non-negative numbers for MonthsCurrent & WeeklyHours for field_name, label in [ ('MonthsCurrent', "number of months you have worked at this job"), ('WeeklyHours', "number of hours you work at this job per week"), ]: v = values.get(field_name) if v is not None and v < 0: errors.append(f"Please enter a non-negative value for the {label}.") if errors: return " ".join(errors) # SCREEN 41 – PERSONAL DEMOGRAPHICS 1 class Screen41_PersonalDemographics1(Page): form_model = 'player' form_fields = [ 'Gender', 'Gender_self', 'Age', 'WorkExperience', 'FoodIndustry', ] @staticmethod def vars_for_template(player: Player): return dict() @staticmethod def error_message(player: Player, values): # If gender is NOT "self-define", wipe out textbox value if values.get('Gender') != "I prefer to self-define": values['Gender_self'] = "" errors = [] # Required questions required = ['Gender', 'Age', 'WorkExperience', 'FoodIndustry'] missing = [f for f in required if values.get(f) in [None, '']] if missing: errors.append("Please answer all questions on this page before continuing.") # If "self-define", require textbox if values.get('Gender') == "I prefer to self-define": if not (values.get('Gender_self') or '').strip(): errors.append("Please specify your gender when selecting 'I prefer to self-define'.") # Age > 18 age = values.get('Age') if age is not None and age < 18: errors.append("You must be older than 18 years to participate in this study.") # Experience fields must be non-negative for f in ['WorkExperience', 'FoodIndustry']: if values.get(f) is not None and values.get(f) < 0: errors.append("Experience values must be non-negative.") if errors: return " ".join(errors) # SCREEN 42 – CONCLUSION class Screen42_Conclusion(Page): @staticmethod def is_displayed(player: Player): # Show this to everyone who reaches post_experiment return True @staticmethod def vars_for_template(player: Player): # Total tokens copied from main_tasks at the end of the last round role = player.participant.vars.get('role') conversion = C.Conversion_GBP total_tokens = int(player.participant.vars.get('CumulativeComp')) raw_usd = float(total_tokens) * 0.01 final_payment = max(raw_usd, 6.00) if role == 'Worker': completion_link = player.session.config['completionlink_worker'] else: completion_link = player.session.config['completionlink_customer'] return dict( total_tokens=total_tokens, conversion=conversion, usd_earnings="{:.2f}".format(raw_usd), is_minimum_applied= raw_usd <6, completion_link=completion_link ) page_sequence = [ Screen33_PostExperimentIntro, Screen34_PEQ_Worker_Part1, Screen35_PEQ_Worker_Part2, Screen36_PEQ_Customer_Part3, Screen37_PEQ_Worker_Part4, Screen38_PEQ_Customer_Part5, Screen39_WorkDemographics1, Screen40_WorkDemographics2, Screen41_PersonalDemographics1, Screen42_Conclusion, ] def custom_export(players): # Header row yield [ 'session_code', 'participant_code', 'participant_label', 'round_number', 'id_in_group', 'role', 'compensation_type', # PEQ – Worker Part 1 'EffortSensitivity_Manager', 'EffortSensitivity_Customer', 'MentalAccount', 'Observability_Manager', 'Observability_Customer', # PEQ – Worker Part 2 'Controllability1', 'Controllability2', 'TipReason_Effort_Worker', 'TipReason_SocialImage_Worker', 'TipReason_SocialNorm_Worker', # PEQ – Customer Part 3 'TipReason_Effort_Customer', 'TipReason_SocialImage_Customer', 'TipReason_SocialNorm_Customer', # Open-ended 'Effort_OpenEnded', 'Tip_OpenEnded', # Work demographics – employer (Screen 39) 'EstablishmentType', 'EstablishmentType_other', 'NumberEmployees', 'AttnCheck2', 'Location', 'Chain', 'ChainNumber', # Work demographics – role (Screen 40) 'Title', 'Title_other', 'MonthsCurrent', 'WeeklyHours', 'Compensation', # Personal demographics (Screen 41) 'Gender', 'Gender_self', 'Age', 'WorkExperience', 'FoodIndustry', ] # Data rows for p in players: yield [ p.session.code, p.participant.code, p.participant.label, p.round_number, p.id_in_group, p.participant.vars.get('role'), p.session.config.get('compensation_type'), # PEQ – Worker Part 1 p.EffortSensitivity_Manager, p.EffortSensitivity_Customer, p.MentalAccount, p.Observability_Manager, p.Observability_Customer, # PEQ – Worker Part 2 p.Controllability1, p.Controllability2, p.TipReason_Effort_Worker, p.TipReason_SocialImage_Worker, p.TipReason_SocialNorm_Worker, # PEQ – Customer Part 3 p.TipReason_Effort_Customer, p.TipReason_SocialImage_Customer, p.TipReason_SocialNorm_Customer, # Open-ended p.Effort_OpenEnded, p.Tip_OpenEnded, # Work demographics – employer p.EstablishmentType, p.EstablishmentType_other, p.NumberEmployees, p.AttnCheck2, p.Location, p.Chain, p.ChainNumber, # Work demographics – role p.Title, p.Title_other, p.MonthsCurrent, p.WeeklyHours, p.Compensation, # Personal demographics p.Gender, p.Gender_self, p.Age, p.WorkExperience, p.FoodIndustry, ]