from otree.api import * from six import integer_types from . import ret_functions import _json # main_tasks/__init__.py (top of file) import json import random import logging logger = logging.getLogger(__name__) doc = """ Main Task """ class C(BaseConstants): NAME_IN_URL = 'main_tasks' PLAYERS_PER_GROUP = 2 NUM_ROUNDS = 4 FixedWage = 200 WageStandard = 160 ServiceCharge = 40 SatisfactionRate = 6 CustomerBase = 50 # Decoding task settings task_time = 120 # seconds class Subsession(BaseSubsession): task_dict = models.LongStringField() sequence = models.LongStringField() class Group(BaseGroup): tip = models.IntegerField(blank=True) effort_level = models.IntegerField(initial=0) class Player(BasePlayer): question = models.LongStringField() key = models.LongStringField() sequence = models.LongStringField() current_task_idx = models.IntegerField(initial=0) role_name = models.StringField(blank=True) correct_answer = models.StringField() task_finished = models.BooleanField(initial=False) satisfaction = models.IntegerField(initial=0) effort = models.IntegerField(initial=0, null=False, blank=True) tip_amount = models.IntegerField(initial=0) compensation = models.CurrencyField(initial=0) cum_compensation = models.CurrencyField(initial=0) cum_payoff = models.CurrencyField(initial=0) bot_q2 = models.StringField( choices=[ ['br', 'Mostly on the bottom right of the square'], ['bl', 'Mostly on the bottom left of the square'], ['ur', 'Mostly on the upper right of the square'], ['ul', 'Mostly on the upper left of the square'], ['center', 'Mostly around the center of the square'], ['off', 'Off-screen (outside the square)'], ], widget=widgets.RadioSelect, label="", ) def role(self): return self.role_name or self.participant.vars.get('role') # 辅助函数:根据 participant.vars['role'] 获取玩家 def get_player_by_custom_role(group, role_name): """根据自定义角色(存储在 participant.vars['role'])获取玩家""" for p in group.get_players(): if p.participant.vars.get('role') == role_name: return p return None def creating_session(subsession): if subsession.field_maybe_none('task_dict') is None: import random, string, json letters = random.sample(string.ascii_uppercase, 24) numbers = random.sample(range(100, 1000), 24) task_dict = dict(zip([str(n) for n in numbers], letters)) subsession.task_dict = json.dumps(task_dict) sequence = list(task_dict.keys()) random.shuffle(sequence) subsession.sequence = json.dumps(sequence) task_dict = json.loads(subsession.task_dict) sequence = json.loads(subsession.sequence) for player in subsession.get_players(): player.current_task_idx = 0 first_number = sequence[0] player.question = first_number player.correct_answer = task_dict[first_number] player.key = subsession.task_dict """ 重要说明: - creating_session 在会话创建时立即执行 - 此时参与者还没有访问 URL,所以 participant.label 可能还是空的 - 角色分配应该由 consent_intro/RoleAssignmentWait 处理 - 这里只做初始分组,不做随机角色分配 """ all_players = subsession.get_players() # 排除提前退出的参与者 players = [ p for p in all_players if not p.participant.vars.get('exit_early', False) ] # 尝试从 participant.vars['role'] 读取角色 (由 consent_intro 设置) # 或从 participant.label 读取 (Room 方式,W 前缀 = Worker, C 前缀 = Customer) for p in players: # 如果角色已经设置,跳过 if p.participant.vars.get('role'): continue # 尝试从 label 推断角色 label = getattr(p.participant, 'label', '') or '' label_upper = label.upper() if label_upper.startswith('W'): p.participant.vars['role'] = 'Worker' elif label_upper.startswith('C'): p.participant.vars['role'] = 'Customer' # 如果没有 label,不做分配,等待 consent_intro 处理 # 获取已分配角色的玩家 customers = [p for p in players if p.participant.vars.get('role') == 'Customer'] workers = [p for p in players if p.participant.vars.get('role') == 'Worker'] # 设置 role_name 和 debug_label customer_idx = 0 worker_idx = 0 for p in sorted(players, key=lambda x: (getattr(x.participant, 'id_in_session', 0), x.id_in_subsession)): role = p.participant.vars.get('role') p.role_name = role or '' if role == 'Customer': customer_idx += 1 desired = f'C{customer_idx:02d}' desired_prefix = 'C' elif role == 'Worker': worker_idx += 1 desired = f'W{worker_idx:02d}' desired_prefix = 'W' else: continue current = getattr(p.participant, 'label', None) # 只有当 label 不正确时才更新 if (not current) or (not isinstance(current, str)) or (not current.upper().startswith(desired_prefix)): p.participant.label = desired p.participant.vars['debug_label'] = getattr(p.participant, 'label', None) or desired # --- 创建分组矩阵 --- if customers and workers: # 真实实验:使用已分配的角色 random.shuffle(customers) random.shuffle(workers) group_matrix = [[c, w] for c, w in zip(customers, workers)] else: # 没有角色分配时:创建占位分组 # 这种情况发生在会话刚创建,参与者还没有访问 URL 时 # 不做随机分配!让 consent_intro 处理角色分配 group_matrix = [] for i in range(0, len(players), 2): if i + 1 < len(players): group_matrix.append([players[i], players[i + 1]]) else: group_matrix.append([players[i]]) # 应用分组 if group_matrix: subsession.set_group_matrix(group_matrix) # ============================================================ # 动态配对函数(模块级函数) # ============================================================ def group_by_arrival_time_method(subsession, waiting_players): """ 动态配对逻辑:按角色一对一配对 - 先到的 Customer 与先到的 Worker 配对 - 如果角色不齐,继续等待 """ try: waiting_debug = [] for p in waiting_players: waiting_debug.append( dict( code=getattr(getattr(p, 'participant', None), 'code', None), label=getattr(getattr(p, 'participant', None), 'label', None), role=getattr(getattr(p, 'participant', None), 'vars', {}).get('role'), last_partner_code=getattr(getattr(p, 'participant', None), 'vars', {}).get('last_partner_code'), ) ) logger.info( 'group_by_arrival_time_method waiting_players: round=%s waiting=%s', getattr(subsession, 'round_number', None), waiting_debug, ) except Exception as e: logger.exception('group_by_arrival_time_method debug log failed: %s', e) customers = [p for p in waiting_players if p.participant.vars.get('role') == 'Customer'] workers = [p for p in waiting_players if p.participant.vars.get('role') == 'Worker'] if not (customers and workers): return None for c in customers: c_last = c.participant.vars.get('last_partner_code') for w in workers: w_last = w.participant.vars.get('last_partner_code') if c_last == w.participant.code: continue if w_last == c.participant.code: continue logger.info( 'group_by_arrival_time_method matched: round=%s customer=%s worker=%s', getattr(subsession, 'round_number', None), dict(code=c.participant.code, label=getattr(c.participant, 'label', None), role=c.participant.vars.get('role')), dict(code=w.participant.code, label=getattr(w.participant, 'label', None), role=w.participant.vars.get('role')), ) return [c, w] return None # ============================================================ # 动态配对等待页(必须为 page_sequence 的第一个页面) # ============================================================ class DynamicMatcherWait(WaitPage): """ 动态配对等待页: - 按到达顺序将 Customer 和 Worker 配对 - 先完成上一轮的玩家可以立即与其他已完成玩家配对 - 无需等待所有组完成 """ group_by_arrival_time = True template_name = 'main_tasks/DynamicMatcherWait.html' title_text = "Pairing..." @staticmethod def is_displayed(player): # 每轮都执行配对 return True @staticmethod def vars_for_template(player): """ 提供实时等待队列信息,增强用户体验 """ # ============================================================ # 关键修复:确保角色基于 label 正确设置 # 如果 role 未设置,根据 participant.label 前缀设置 # ============================================================ if not player.participant.vars.get('role'): label = getattr(player.participant, 'label', '') or '' label_upper = label.upper() if label_upper.startswith('W'): player.participant.vars['role'] = 'Worker' elif label_upper.startswith('C'): player.participant.vars['role'] = 'Customer' # 同时设置 Player.role_name player.role_name = player.participant.vars.get('role') or '' round_number = player.round_number total_rounds = C.NUM_ROUNDS # 获取当前轮次所有玩家(包括已配对和等待中的) all_players = player.subsession.get_players() # 统计当前等待的 Customer 和 Worker 数量 # 注意:这里统计的是所有在当前轮次的玩家,实际等待中的数量由 oTree 内部管理 customers_count = len([p for p in all_players if p.participant.vars.get('role') == 'Customer']) workers_count = len([p for p in all_players if p.participant.vars.get('role') == 'Worker']) # 获取当前玩家角色 player_role = player.participant.vars.get('role', 'Unknown') participant_code = player.participant.code last_partner_code = player.participant.vars.get('last_partner_code') customers_sorted = [p for p in all_players if p.participant.vars.get('role') == 'Customer'] workers_sorted = [p for p in all_players if p.participant.vars.get('role') == 'Worker'] customers_sorted.sort(key=lambda p: (getattr(p.participant, 'id_in_session', 0), p.id_in_subsession)) workers_sorted.sort(key=lambda p: (getattr(p.participant, 'id_in_session', 0), p.id_in_subsession)) self_label = getattr(player.participant, 'label', None) or '(no label)' last_partner_label = None if last_partner_code: partner = next((p for p in all_players if p.participant.code == last_partner_code), None) last_partner_label = getattr(getattr(partner, 'participant', None), 'label', None) if partner else None last_partner_label = last_partner_label or '(unknown)' # 根据角色显示对应的等待对象 if player_role == 'Customer': waiting_for = 'Worker' elif player_role == 'Worker': waiting_for = 'Customer' else: waiting_for = 'Unknown' progress_percent = int(round_number * 100 / total_rounds) if total_rounds else 0 return dict( customers_count=customers_count, workers_count=workers_count, player_role=player_role, self_label=self_label, participant_code=participant_code, last_partner_code=last_partner_code, last_partner_label=last_partner_label, waiting_for=waiting_for, round_number=round_number, total_rounds=total_rounds, progress_percent=progress_percent, ) # PAGES class Screen_BotsTrap2(Page): form_model = 'player' form_fields = ['bot_q2'] def is_displayed(player): # 👇 ONLY show after round 2 (once) return player.round_number == 2 def before_next_page(player, timeout_happened): player.participant.vars['bot_failed_trap2'] = (player.bot_q2 != 'center') class Screen20(Page): def is_displayed(player): return (player.participant.vars.get('role') == "Customer" and player.session.config['compensation_type'] == 'Pre_tip') def vars_for_template(self): round_number = self.round_number return { 'round_display': f"(ROUND {round_number})", } class Screen21a_PreTip_Reminder(Page): def is_displayed(player): return (player.participant.vars.get('role') == "Customer" and player.session.config['compensation_type'] == 'Pre_tip') class Screen21_PreTip_Decision(Page): form_model = 'group' form_fields = ['tip'] def is_displayed(player): return (player.participant.vars.get('role') == "Customer" and player.session.config['compensation_type'] == 'Pre_tip') def vars_for_template(self): round_number = self.round_number return { 'round_display': f"(ROUND {round_number})", } def error_message(self,values): tip = values.get('tip', 0) if tip < 0 or tip > 80: return 'Tip must be between 0 and 80 tokens' if not isinstance(tip, int) or tip != int(tip): return 'Tip must be a whole number' class Screen22_DecodingTaskIntro(Page): def is_displayed(self): return self.participant.vars.get('role') == "Worker" def vars_for_template(self): round_number = self.round_number comp = self.session.config.get('compensation_type') tip_value = self.group.field_maybe_none('tip') tip_is_ready = tip_value is not None wage = C.FixedWage if comp == 'Fixed_wage' else C.WageStandard show_next = tip_is_ready if ('Pre_tip' in comp) else True return dict( round_display=f"(ROUND {round_number})", round_number=round_number, compensation_type=comp, wage=wage, tip_is_ready=tip_is_ready, show_next_button=show_next, ) class Screen22a_WaitForTip(WaitPage): template_name = 'main_tasks/Screen22a_WaitForTip.html' def is_displayed(self): # Only workers in the Pre_tip condition need to wait return (self.participant.vars.get('role') == "Worker" and 'Pre_tip' in self.session.config.get('compensation_type')) # This is the "keep on waiting" logic def after_all_members_proceed(self): pass class Screen23_TipDisplay(Page): def is_displayed(self): return (self.participant.vars.get('role') == "Worker" and self.session.config['compensation_type'] == 'Pre_tip') def vars_for_template(self): round_number = self.round_number tip_amount = self.group.tip return { 'round_display': f"(ROUND {round_number})", 'round_number': round_number, 'tip_amount': tip_amount, } # ============================================================ # LIVE HANDLER (MAIN TASK) # ============================================================ def live_decoding(player, data): if player.task_finished: return {} msg_type = data.get('type') # Ignore heartbeat if msg_type == 'ping': return {} # End task if msg_type == 'end': player.task_finished = True return {player.id_in_group: {'type': 'end'}} # Handle answer submission if msg_type == 'answer_submit': task_dict = json.loads(player.key) sequence = json.loads(player.subsession.sequence) submitted = data.get('answer', '').strip().upper() correct_answer = (player.correct_answer or '').strip().upper() # ------------------------ # CORRECT ANSWER # ------------------------ if submitted == correct_answer: # increment effort player.effort = (player.effort or 0) + 1 # move to next question player.current_task_idx += 1 if player.current_task_idx >= len(sequence): player.current_task_idx = 0 feedback_type = 'correct' # ------------------------ # INCORRECT ANSWER # ------------------------ else: feedback_type = 'incorrect' # update next task next_number = sequence[player.current_task_idx] player.question = next_number player.correct_answer = task_dict[next_number] task_pairs = sorted(task_dict.items(), key=lambda x: x[1]) return { player.id_in_group: { 'type': feedback_type, 'effort': player.effort, # ALWAYS SEND BACK 'question': player.question, 'task_pairs': task_pairs, 'message': 'Incorrect. Try again!' if feedback_type == 'incorrect' else '', } } return {} # ============================================================ # MAIN TASK PAGE (DECODING) # ============================================================ class Screen24_DecodingTask(Page): live_method = live_decoding timeout_seconds = C.task_time timer_text = "Time remaining:" stay_on_page = True def is_displayed(player): return player.participant.vars.get('role') == "Worker" def vars_for_template(player): import json raw_key = player.field_maybe_none('key') or '{}' task_dict = json.loads(raw_key) raw_sequence = player.subsession.field_maybe_none('sequence') or '[]' sequence = json.loads(raw_sequence) current_idx = player.field_maybe_none('current_task_idx') or 0 current_question = sequence[current_idx] if current_idx < len(sequence) else None task_pairs = sorted(task_dict.items(), key=lambda x: x[1]) return dict( question=current_question, task_pairs=task_pairs, effort=player.field_maybe_none('effort') or 0, task_time=C.task_time, round_display=f"ROUND {player.round_number}", ) class Screen24a_Wait_for_Worker(WaitPage): template_name = 'main_tasks/Screen24a_Wait_for_Worker.html' title_text = "Please wait for the worker to finish the task." def after_all_players_arrive(group: Group): worker = get_player_by_custom_role(group, 'Worker') customer = get_player_by_custom_role(group, 'Customer') # 安全检查:确保两个角色都存在 if not worker or not customer: return comp = (group.session.config.get('compensation_type') or '').strip().lower() worker_effort = worker.effort or 0 group.effort_level = worker_effort customer.satisfaction = C.SatisfactionRate * worker_effort tip_value = group.field_maybe_none('tip') or 0 worker.tip_amount = tip_value customer.tip_amount = tip_value if comp == 'fixed_wage': worker.payoff = cu(C.FixedWage) customer.payoff = cu(C.CustomerBase + customer.satisfaction) elif comp == 'service_charge': worker.payoff = cu(C.WageStandard + C.ServiceCharge) customer.payoff = cu(C.CustomerBase + customer.satisfaction - C.ServiceCharge) elif comp == 'pre_tip': worker.payoff = cu(C.WageStandard + tip_value) customer.payoff = cu(C.CustomerBase + customer.satisfaction - tip_value) elif comp == 'post_tip': pass for p in group.get_players(): p.cum_payoff = sum((pr.payoff for pr in p.in_rounds(1, p.round_number)), cu(0)) class Screen25_CustomerPayoff(Page): def is_displayed(self): comp = (self.session.config.get('compensation_type') or '').strip().lower() return (self.participant.vars.get('role') == "Customer") # question: shouldn't pre_tip condition participants also know the decoding tasks completed by workers? def vars_for_template(self): round_number = self.round_number comp = (self.session.config.get('compensation_type') or '').strip().lower() worker = get_player_by_custom_role(self.group, 'Worker') effort = (worker.effort or 0) if worker else 0 return dict( comp = comp, round_display=f"(ROUND {self.round_number})", effort=effort, # the only dynamic number you need here customer_base=C.CustomerBase, ) class Screen26_PostTip_Decision(Page): form_model = 'group' form_fields = ['tip'] def is_displayed(player): return (player.participant.vars.get('role') == "Customer" and player.session.config['compensation_type'] == 'Post_tip') def vars_for_template(self): round_number = self.round_number worker = self.group.get_player_by_role("Worker") effort = (worker.effort or 0) if worker else 0 return { 'round_display': f"(ROUND {round_number})", 'effort': effort, } def error_message(self,values): tip = values.get('tip', 0) if tip < 0 or tip > 80: return 'Tip must be between 0 and 80 tokens' if not isinstance(tip, int) or tip != int(tip): return 'Tip must be a whole number' class Screen26a_Wait_for_Tip(WaitPage): template_name = 'main_tasks/Screen26a_Wait_for_Tip.html' def is_displayed(self): return self.session.config.get('compensation_type') == 'Post_tip' def after_all_players_arrive(group: Group): worker = get_player_by_custom_role(group, 'Worker') customer = get_player_by_custom_role(group, 'Customer') # 安全检查:确保两个角色都存在 if not worker or not customer: return tip_value = group.field_maybe_none('tip') or 0 worker.tip_amount = tip_value customer.tip_amount = tip_value worker.payoff = cu(C.WageStandard + tip_value) customer.payoff = cu(C.CustomerBase + customer.satisfaction - tip_value) for p in group.get_players(): p.cum_payoff = sum(pr.payoff for pr in p.in_rounds(1, p.round_number)) class Screen27_PostTipDisplay(Page): def is_displayed(self): return (self.participant.vars.get('role') == "Worker" and self.session.config['compensation_type'] == 'Post_tip') def vars_for_template(self): round_number = self.round_number tip_amount = self.tip_amount return { 'round_display': f"(ROUND {round_number})", 'round_number': round_number, 'tip_amount': tip_amount, } class Screen28_WorkerCompensation(Page): @staticmethod def is_displayed(player): return player.participant.vars.get('role') == 'Worker' @staticmethod def vars_for_template(player): round_number = player.round_number compensation = int(player.payoff or cu(0)) tip_raw = player.tip_amount effort = player.effort or 0 return { 'round_display': f"(ROUND {round_number})", 'round_number': round_number, 'compensation': compensation, 'tip_display': tip_raw if tip_raw is not None else 0, 'effort': effort, } class Screen29_WorkerCumCompensation(Page): def is_displayed(self): return self.participant.vars.get('role') == 'Worker' def vars_for_template(self): comp = (self.session.config.get('compensation_type') or '').strip().lower() rounds = self.in_rounds(1, self.round_number) rows = [] cumulative = cu(0) for pr in rounds: total = int(pr.payoff or cu(0)) cumulative += total # Show 200 for fixed_wage, otherwise 160 (per your constants) wage = C.FixedWage if comp == 'fixed_wage' else C.WageStandard # Build a row with safe defaults for all columns row = { 'round_index': pr.round_number, 'wage': wage, 'total': total, 'service_charge': 0, 'tip_display': 0, # ensure the template key always exists } if comp == 'service_charge': row['service_charge'] = C.ServiceCharge elif comp in ('pre_tip', 'post_tip'): tip_val = pr.tip_amount if pr.tip_amount is not None else (pr.group.tip or 0) row['tip_display'] = tip_val rows.append(row) return dict( round_display=f"(ROUND {self.round_number})", round_number=self.round_number, comp=comp, # 'fixed_wage' | 'service_charge' | 'pre_tip' | 'post_tip' rows=rows, # one dict per past round (incl. current) cumulative_comp=int(cumulative), # sum of totals up to current round ) class Screen30_CustomerPayoff(Page): def is_displayed(self): return self.participant.vars.get('role') == "Customer" def vars_for_template(self): comp = (self.session.config.get('compensation_type') or '').strip().lower() # Components base_payoff = C.CustomerBase # 60 # Use the value you stored on the customer in the wait page (preferred), # otherwise compute from current worker effort. satisfaction = self.satisfaction or 0 if not satisfaction: worker = get_player_by_custom_role(self.group, 'Worker') satisfaction = ((worker.effort or 0) * C.SatisfactionRate) if worker else 0 tip_display = 0 service_charge_display = 0 if comp in ('pre_tip', 'post_tip'): tip_display = self.tip_amount or 0 elif comp == 'service_charge': service_charge_display = C.ServiceCharge total = int(self.payoff or cu(0) ) # already computed in your wait page(s) return dict( round_display=f"(ROUND {self.round_number})", round_number=self.round_number, comp=comp, base_payoff=base_payoff, satisfaction_display=satisfaction, tip_display=tip_display, service_charge_display=service_charge_display, total=total, ) class Screen31_CustomerCumPayoff(Page): def is_displayed(self): return self.participant.vars.get('role') == 'Customer' def vars_for_template(self): comp = (self.session.config.get('compensation_type') or '').strip().lower() past_rounds = self.in_rounds(1, self.round_number) rows = [] cumulative = cu(0) for pr in past_rounds: # total payoff for the customer (already computed in your wait pages) total = int(pr.payoff or cu(0)) cumulative += total row = { 'round_index': pr.round_number, 'base': C.CustomerBase, # 60 'satisfaction': pr.satisfaction or 0, # Number of sliders * 200, set in wait page 'total': total, } if comp == 'service_charge': row['service_charge'] = C.ServiceCharge # 40 elif comp in ('pre_tip', 'post_tip'): # Tip saved each round on Player.tip_amount; fallback to group.tip just in case row['tip'] = pr.tip_amount if pr.tip_amount is not None else (pr.group.tip or 0) rows.append(row) return dict( round_display=f"(ROUND {self.round_number})", round_number=self.round_number, comp=comp, # 'fixed_wage' | 'service_charge' | 'pre_tip' | 'post_tip' rows=rows, # one row per round 1..current cumulative_payoff=int(cumulative), # sum of totals so far ) class Screen32_WaitRepair(WaitPage): """ Shown to BOTH roles after the round ends. - Rounds 1..(NUM_ROUNDS-1): waiting + re-pairing message - Final round (C.NUM_ROUNDS): waiting for others to finish; no more re-pairing. Also stores cumulative compensation into participant.vars['CumulativeComp']. 注意:已改为组内等待,不再全局阻塞 """ wait_for_all_groups = False # 改为 False:仅等待组内玩家 template_name = 'main_tasks/Screen32_WaitRepair.html' @staticmethod def is_displayed(player: Player): # Show this every round to everyone return True @staticmethod def vars_for_template(player: Player): other_role = 'Worker' if player.role() == 'Customer' else 'Customer' final_round = (player.round_number == C.NUM_ROUNDS) return dict( round_display=f"(ROUND {player.round_number})", other_role=other_role, final_round=final_round, ) @staticmethod def after_all_players_arrive(group: Group): """ 改为 Group 函数(因为 wait_for_all_groups = False)。 在最后一轮,将每个玩家的累计收益保存到 participant.vars。 """ players = group.get_players() if len(players) == 2: p1, p2 = players p1.participant.vars['last_partner_code'] = p2.participant.code p2.participant.vars['last_partner_code'] = p1.participant.code if group.subsession.round_number == C.NUM_ROUNDS: for p in group.get_players(): # 将累计收益保存到 participant.vars p.participant.vars['CumulativeComp'] = p.cum_payoff page_sequence = [DynamicMatcherWait, # 动态配对等待页(必须是第一个) Screen20, Screen21_PreTip_Decision, Screen22_DecodingTaskIntro, Screen22a_WaitForTip, Screen23_TipDisplay, Screen24_DecodingTask, Screen24a_Wait_for_Worker, Screen25_CustomerPayoff, Screen26_PostTip_Decision, Screen26a_Wait_for_Tip, Screen27_PostTipDisplay, Screen28_WorkerCompensation, Screen29_WorkerCumCompensation, Screen30_CustomerPayoff, Screen31_CustomerCumPayoff, Screen32_WaitRepair, Screen_BotsTrap2, ] # ============================================================ # CUSTOM EXPORT FUNCTION # ============================================================ def custom_export(players): """Add a role column and other key info to your export.""" yield [ 'session_code', 'participant_code', 'round_number', 'role', 'effort', 'satisfaction', 'tip_amount', 'payoff', 'cumulative_payoff', ] for p in players: yield [ p.session.code, p.participant.code, p.round_number, p.role(), # Uses your Player.role() method p.effort, p.satisfaction, p.tip_amount, p.payoff, p.cum_payoff, ]