import json import os import random import logging from otree.api import ( models, widgets, BaseConstants, BaseSubsession, BaseGroup, BasePlayer, Currency as c, currency_range, ExtraModel ) from .org_chart_utils import ( select_partners, get_node_label, get_relationship_type, get_path_to_ceo, get_organizational_distance ) doc = """ Organizational Learning Experiment - Asynchronous Version Participants join sequentially and are matched with previous participants based on organizational hierarchy relationships. """ logger = logging.getLogger(__name__) def _debug_enabled() -> bool: """Match settings.py convention: DEBUG when OTREE_PRODUCTION is unset or '0'.""" return os.environ.get('OTREE_PRODUCTION') in [None, '', '0'] # Note: ExtraModels (CompletedRound, ParticipantRegistry) are defined at the end of the file # after the Player class, because they need to link to Player. class Constants(BaseConstants): name_in_url = 'org_learning' players_per_group = None # Each player is in their own group (async) # Maximum possible rounds (for player 4+ with extra superior) # 4 individual + 3 per partner * 4 partners = 16 rounds num_rounds = 16 # Number of rounds per type INDIVIDUAL_ROUNDS = 4 ROUNDS_PER_PARTNER = 3 # 1 signal + 1 action + 1 action-known = 3 per partner @staticmethod def get_num_rounds_for_participant(join_order, has_extra_superior=False): """ Calculate total rounds based on when participant joined. - Player 1: 4 individual only - Player 2: 4 individual + 3 with P1 (1 signal + 1 action + 1 action-known) = 7 - Player 3: 4 individual + 3 with P1 + 3 with P2 = 10 - Player 4+: 4 individual + 3*3 = 13 (or 16 if extra superior available) """ if join_order == 1: return 4 # Individual only elif join_order == 2: return 7 # 4 + 3 elif join_order == 3: return 10 # 4 + 3 + 3 else: # 13 base, or 16 if extra superior available return 16 if has_extra_superior else 13 @staticmethod def get_num_partners(join_order, has_extra_superior=False): """Get number of partners based on join order.""" if join_order == 1: return 0 elif join_order == 2: return 1 elif join_order == 3: return 2 else: # 3 base partners, or 4 if extra superior available return 4 if has_extra_superior else 3 def perform_draw(red_count, num_balls=3): """ Perform a draw from a physical urn of 20 balls. Uses sampling WITHOUT replacement for a more realistic distribution. """ urn = (['red'] * red_count) + (['white'] * (20 - red_count)) return random.sample(urn, num_balls) class Subsession(BaseSubsession): def creating_session(self): """ Pre-generate randomness that does NOT depend on partner matching. We fix the practice (demo) urn/draws and the participant's individual-round urn/draw sequences at session creation time so they are determined before the participant begins interacting with the session. Paired-round content is still generated later (at Intake) because it depends on who has already joined and which partners are available. """ if self.round_number != 1: return for player in self.get_players(): participant = player.participant # Practice/Demo: one urn and two draw sequences (3 balls each). if participant.vars.get('demo_red') is None or participant.vars.get('demo_draws') is None: demo_red = random.randint(4, 16) demo_draws = json.dumps(perform_draw(demo_red) + perform_draw(demo_red)) participant.vars['demo_red'] = demo_red participant.vars['demo_draws'] = demo_draws # Individual rounds: pre-generate 4 urns and two draw batches (3 balls each) per urn. if not participant.vars.get('pre_drawn_individual_rounds'): pre_drawn = [] for _ in range(Constants.INDIVIDUAL_ROUNDS): red_count = random.randint(4, 16) draw_batches = [perform_draw(red_count) for _ in range(2)] pre_drawn.append({ 'urn_red_balls': red_count, 'individual_draws_json': json.dumps(draw_batches), }) participant.vars['pre_drawn_individual_rounds'] = pre_drawn # Persist to Player fields so it is visible immediately in standard exports # (participant.vars is stored, but not included in all_apps_wide CSV). player.pre_drawn_demo_red = participant.vars.get('demo_red') player.pre_drawn_demo_draws_json = participant.vars.get('demo_draws') player.pre_drawn_individual_rounds_json = json.dumps( participant.vars.get('pre_drawn_individual_rounds') or [] ) def get_completed_participants(subsession): """Get all participants who have completed their experiment from the registry.""" return ParticipantRegistry.filter(subsession=subsession) def get_next_join_order(subsession): """Get the next join order number for a new participant.""" existing = ParticipantRegistry.filter(subsession=subsession) if not existing: return 1 return max(p.join_order for p in existing) + 1 def register_participant(subsession, participant, node_id, first_name, role_title, demo_urn, demo_draws): """ Register a new participant and determine their join order and partners. Returns the join_order and list of partner participant_codes. """ from datetime import datetime join_order = get_next_join_order(subsession) # Get available participants (excluding self) available = [ {'node_id': p.node_id, 'participant_code': p.participant_code, 'first_name': p.first_name, 'role_title': p.role_title} for p in ParticipantRegistry.filter(subsession=subsession) if p.participant_code != participant.code and p.join_order < join_order ] # Check whether an *extra chain-of-command* partner is available (for join_order >= 4). # We trigger the 4th partner if either: # - 2+ superiors (ancestors) are available, OR # - 2+ subordinates (descendants) are available. # This fits org charts with only a few layers where both can't be true at once. has_extra_superior = False if join_order >= 4 and available: from .org_chart_utils import get_ancestors, get_all_descendants ancestors = set(get_ancestors(node_id) or []) descendants = set(get_all_descendants(node_id) or []) available_superior_count = sum(1 for p in available if p['node_id'] in ancestors) available_subordinate_count = sum(1 for p in available if p['node_id'] in descendants) has_extra_superior = (available_superior_count >= 2) or (available_subordinate_count >= 2) num_partners = Constants.get_num_partners(join_order, has_extra_superior) num_rounds = Constants.get_num_rounds_for_participant(join_order, has_extra_superior) # Register the participant ParticipantRegistry.create( subsession=subsession, participant_code=participant.code, node_id=node_id, first_name=first_name, role_title=role_title, join_order=join_order, num_rounds=num_rounds, demo_urn_red=demo_urn, demo_draws_json=demo_draws, completed=False, registered_at=datetime.now().isoformat() ) # Select partners based on org hierarchy partners = [] if num_partners > 0: # Use org chart algorithm to select partners (now supports extra superior) selected = select_partners(node_id, available, num_partners, include_extra_superior=has_extra_superior) partners = [p['participant_code'] for p in selected] return join_order, partners, num_rounds def update_participant_registry(subsession, participant, **kwargs): """ Update the static data in the ParticipantRegistry. Used for Survey, Demographics, and Likert scales. """ # IMPORTANT: ParticipantRegistry rows are created during Intake (round 1), # but Survey/Demographics/Likert run on the participant's final round. # Since `subsession` is per-round in oTree, filtering by subsession would # fail to find the row. `participant.code` is unique, so use that. reg = None root_subsession = subsession.in_round(1) regs = [ r for r in ParticipantRegistry.filter(subsession=root_subsession) if r.participant_code == participant.code ] if regs: reg = regs[0] if reg: for key, value in kwargs.items(): if hasattr(reg, key): setattr(reg, key, value) return reg def generate_round_plan(subsession, join_order, partners, node_id): """ Generate a randomized round plan for a participant. Args: player: The current player (for ExtraModel filtering) join_order: The participant's join order (1, 2, 3, 4+) partners: List of partner participant_codes node_id: The participant's org chart node ID Returns: List of round definitions in randomized order """ rounds = [] # Always add 6 individual rounds for i in range(Constants.INDIVIDUAL_ROUNDS): rounds.append({ 'round_type': 'individual', 'treatment': f'individual_{i+1}', 'pair_mode': '', 'partner_code': None, 'partner_round_index': None, }) # Add paired rounds for each partner for partner_code in partners: # Get partner info partner_reg = None for p in ParticipantRegistry.filter(subsession=subsession): if p.participant_code == partner_code: partner_reg = p break if not partner_reg: continue # Get partner's completed rounds to use as historical data partner_rounds = [r for r in CompletedRound.filter(subsession=subsession) if r.participant_code == partner_code] # For each pair mode, add 6 rounds with this partner # For each pair mode, add 1 round with this partner (Total 3) for mode_idx, pair_mode in enumerate(['signal', 'action', 'action-known']): # Select which of the partner's rounds to use # Prefer using different rounds, cycle if needed partner_round_index = (mode_idx % len(partner_rounds)) + 1 if partner_rounds else 1 rounds.append({ 'round_type': 'pair', 'treatment': f'{pair_mode}_with_{partner_code}', 'pair_mode': pair_mode, 'partner_code': partner_code, 'partner_round_index': partner_round_index, 'partner_node_id': partner_reg.node_id, 'partner_first_name': partner_reg.first_name, 'partner_role_title': partner_reg.role_title, 'relationship': get_relationship_type(node_id, partner_reg.node_id), }) # Shuffle all rounds random.shuffle(rounds) return rounds def initialize_participant_history(subsession, participant, drawing_room): """ Pre-populate the history registry with pre-drawn individual round data. Makes this participant's data available for matching immediately after Intake. """ from datetime import datetime root_subsession = subsession.in_round(1) reg = None for p in ParticipantRegistry.filter(subsession=root_subsession): if p.participant_code == participant.code: reg = p break if not reg: return for round_num, data in drawing_room.items(): if data['round_type'] == 'individual': # Idempotency: avoid creating duplicate placeholders if Intake is # somehow submitted twice (testing/back button/reload). existing = [ r for r in CompletedRound.filter(subsession=root_subsession) if r.participant_code == participant.code and r.round_index == round_num ] if existing: continue # Create history entry placeholder with pre-drawn data CompletedRound.create( subsession=root_subsession, participant_code=participant.code, participant_node_id=reg.node_id, participant_label=reg.first_name, participant_role=reg.role_title, round_index=round_num, round_type='individual', urn_red_balls=data['urn_red_balls'], draws_json=data['individual_draws_json'], first_guess=0, # Placeholder, will be updated when round is played updated_guess=0, completed_at=datetime.now().isoformat() ) def save_completed_round(subsession, participant, round_index, round_type, urn_red_balls, draws_json, first_guess, updated_guess=None): """Save or update a completed round in the historical data store.""" from datetime import datetime # IMPORTANT: CompletedRound entries are initialized during Intake (round 1) # and must be updated later when the participant actually plays the round. # Since `subsession` is per-round in oTree, always read/write these records # through the round-1 subsession so lookups are stable. root_subsession = subsession.in_round(1) # Try to find existing placeholder from initialization existing = [r for r in CompletedRound.filter(subsession=root_subsession) if r.participant_code == participant.code and r.round_index == round_index] if existing: res = existing[0] res.first_guess = first_guess res.updated_guess = updated_guess res.completed_at = datetime.now().isoformat() return # Fallback to create if no placeholder (should not happen with new flow) reg = None for p in ParticipantRegistry.filter(subsession=root_subsession): if p.participant_code == participant.code: reg = p break if not reg: return CompletedRound.create( subsession=root_subsession, participant_code=participant.code, participant_node_id=reg.node_id, participant_label=reg.first_name, participant_role=reg.role_title, round_index=round_index, round_type=round_type, urn_red_balls=urn_red_balls, draws_json=draws_json, first_guess=first_guess, updated_guess=updated_guess, completed_at=datetime.now().isoformat() ) def mark_participant_completed(subsession, participant): """Mark a participant as having completed all their rounds.""" root_subsession = subsession.in_round(1) regs = [ r for r in ParticipantRegistry.filter(subsession=root_subsession) if r.participant_code == participant.code ] if regs: reg = regs[0] reg.completed = True def get_partner_historical_data(subsession, partner_code, round_index=None): """ Get historical round data for a partner. If round_index is specified, get that specific round. Otherwise, return all rounds. """ root_subsession = subsession.in_round(1) rounds = [r for r in CompletedRound.filter(subsession=root_subsession) if r.participant_code == partner_code] if round_index: rounds = [r for r in rounds if r.round_index == round_index] return list(rounds) def generate_synthetic_historical_data(urn_red_balls): """ Generate synthetic historical data when partner hasn't completed rounds yet. This creates believable fake data based on the urn configuration. """ import json # Generate draws consistent with the urn prob_red = urn_red_balls / 20 draws = [] for _ in range(3): color = 'red' if random.random() < prob_red else 'white' draws.append(color) # Generate a guess that's somewhat reasonable given the urn # Add some noise to make it seem realistic base_guess = urn_red_balls noise = random.randint(-2, 2) first_guess = max(4, min(16, base_guess + noise)) # Updated guess is similar but might shift based on "seeing" draws red_count_in_draws = draws.count('red') if red_count_in_draws >= 2: shift = random.randint(0, 2) elif red_count_in_draws == 0: shift = random.randint(-2, 0) else: shift = random.randint(-1, 1) updated_guess = max(4, min(16, first_guess + shift)) return { 'first_guess': first_guess, 'updated_guess': updated_guess, 'draws_json': json.dumps(draws), 'urn_red_balls': urn_red_balls, 'round_type': 'individual', # Synthetic data } def get_partner_available_urns(subsession, partner_code): """ Get the list of urn configurations (red ball counts) that a partner has completed in their INDIVIDUAL rounds only. These are the only rounds with authentic data. Returns ALL urn instances (with duplicates), not just unique values. Used to randomly sample from partner's actual experience. Returns: List of urn_red_balls values from partner's completed individual rounds (may contain duplicates) Returns empty list if partner has no completed individual rounds. """ # Only get INDIVIDUAL rounds - that's where authentic urn/draw/guess data comes from root_subsession = subsession.in_round(1) partner_rounds = [r for r in CompletedRound.filter(subsession=root_subsession) if r.participant_code == partner_code and r.round_type == 'individual'] # Defensive de-duplication: if placeholder rows were accidentally created # more than once, keep only one record per round_index. by_round_index = {} for r in partner_rounds: if r.round_index not in by_round_index: by_round_index[r.round_index] = r partner_rounds = list(by_round_index.values()) if not partner_rounds: # No historical data - return empty list (caller should handle this) if _debug_enabled(): logger.warning("No completed individual rounds for partner %s", partner_code) return [] # Get urn configurations WITH duplicates (one per completed round) urn_list = [r.urn_red_balls for r in partner_rounds] # Get unique urn configurations with their counts for debugging urn_counts = {} for r in partner_rounds: urn_counts[r.urn_red_balls] = urn_counts.get(r.urn_red_balls, 0) + 1 if _debug_enabled(): logger.debug( "Partner %s has %s completed individual rounds. Urn distribution: %s", partner_code, len(partner_rounds), urn_counts, ) # Return ALL instances, not unique - this allows proper sampling from partner's experience return urn_list def get_historical_data_for_paired_round(subsession, partner_code, pair_mode, urn_red_balls): """ Get appropriate historical data for a paired round. Finds a completed INDIVIDUAL round from the partner that matches the urn configuration. Uses ONLY the first batch of draws and first guess from individual rounds. Args: subsession: The subsession (for ExtraModel filtering) partner_code: The partner's participant code pair_mode: 'signal', 'action', or 'action-known' urn_red_balls: The urn configuration for this round Returns: Dict with partner's draw and first_guess, or synthetic data if no real guess exists """ # Only get INDIVIDUAL rounds from the partner - that's where their authentic data is # We ignore placeholders (first_guess == 0) for matching purposes if possible root_subsession = subsession.in_round(1) partner_rounds = [r for r in CompletedRound.filter(subsession=root_subsession) if r.participant_code == partner_code and r.round_type == 'individual'] # Defensive de-duplication: keep only one record per round_index. by_round_index = {} for r in partner_rounds: if r.round_index not in by_round_index: by_round_index[r.round_index] = r partner_rounds = list(by_round_index.values()) if not partner_rounds: # No history at all? Use synthetic. return generate_synthetic_historical_data(urn_red_balls) # Prefer: same-urn round with a real guess (first_guess > 0) matching = [r for r in partner_rounds if r.urn_red_balls == urn_red_balls and (r.first_guess or 0) > 0] selected = None if matching: selected = random.choice(matching) else: # Next best: same-urn round even if it's still a placeholder (guess missing). same_urn_any = [r for r in partner_rounds if r.urn_red_balls == urn_red_balls] if same_urn_any: selected = random.choice(same_urn_any) if not selected: # No same-urn data at all. return generate_synthetic_historical_data(urn_red_balls) # If the selected record doesn't have a real guess yet, generate a synthetic guess # but keep the selected draws so the urn/draws stay matched. selected_first_guess = selected.first_guess if (selected.first_guess or 0) > 0 else generate_synthetic_historical_data(urn_red_balls)['first_guess'] # Extract draws - individual rounds store as 2D array [[batch1], [batch2]] draws_json = selected.draws_json try: draws = json.loads(draws_json) if isinstance(draws, list) and len(draws) > 0 and isinstance(draws[0], list): draws = draws[0] draws_json = json.dumps(draws) except (json.JSONDecodeError, TypeError, IndexError): pass return { 'first_guess': selected_first_guess, 'updated_guess': selected_first_guess, 'draws_json': draws_json, 'urn_red_balls': selected.urn_red_balls, 'round_type': selected.round_type, } class Group(BaseGroup): pass class Player(BasePlayer): # CONSENT - Only used for form handling on page consent_agreed = models.BooleanField( label="I consent to participate in this study" ) # IDENTITY - Only used for form handling on Intake page org_chart_position = models.IntegerField( label="What is your position ID on the organization chart?", min=0, max=122 ) role_title = models.StringField( label="What is your role title on the organization chart?", max_length=150 ) first_name = models.StringField( label="What is your first name?", max_length=100 ) # Practice/Demo (Captures the guess for export, the draw info is in Registry) demo_guess = models.IntegerField( label="In this example, how many red balls do you think are in the urn?", min=4, max=16 ) # Pre-drawn randomness (persisted at session creation so it exists before the participant starts) # These fields exist mainly so that standard exports (all_apps_wide) include the pre-draws. pre_drawn_demo_red = models.IntegerField(min=4, max=16, blank=True) pre_drawn_demo_draws_json = models.LongStringField(blank=True) pre_drawn_individual_rounds_json = models.LongStringField(blank=True) # Post-experiment survey data mirrored onto Player for standard CSV export. # (Authoritative copy remains in ParticipantRegistry.) advice_json = models.LongStringField(blank=True) friends_json = models.LongStringField(blank=True) insight_json = models.LongStringField(blank=True) partner_ratings_json = models.LongStringField(blank=True) participant_comments = models.LongStringField( blank=True, label='Any comments? (Optional)' ) # Matching debug/export helpers (populated at Intake; safe for production). assigned_join_order = models.IntegerField(blank=True) assigned_partners_json = models.LongStringField(blank=True) # ROUND-SPECIFIC DATA (Changes every round) round_type = models.StringField(blank=True) # 'individual', 'pair' round_treatment = models.StringField(blank=True) pair_mode = models.StringField(blank=True) # 'signal', 'action', 'action-known' # Partner information (for paired rounds - specific to this round) partner_code = models.StringField(blank=True) partner_label = models.StringField(blank=True) partner_first_name = models.StringField(blank=True) partner_role_title = models.StringField(blank=True) partner_node_id = models.IntegerField(blank=True) partner_relationship = models.StringField(blank=True) # Historical partner data shown to participant partner_historical_guess = models.IntegerField(min=4, max=16, blank=True) partner_historical_updated_guess = models.IntegerField(min=4, max=16, blank=True) partner_historical_draw_json = models.LongStringField(blank=True) # Urn and draw data for this specific round urn_red_balls = models.IntegerField(min=4, max=16, blank=True) individual_draws_json = models.LongStringField(blank=True) signal_draw_json = models.LongStringField(blank=True) signal_partner_draw_json = models.LongStringField(blank=True) action_draw_json = models.LongStringField(blank=True) action_partner_guess = models.IntegerField(min=4, max=16, blank=True) action_known_draw_json = models.LongStringField(blank=True) action_known_own_guess = models.IntegerField(min=4, max=16, blank=True) action_known_partner_guess = models.IntegerField(min=4, max=16, blank=True) # Behavioral Guesses individual_guess_1 = models.IntegerField(min=4, max=16, blank=True) individual_guess_2 = models.IntegerField(min=4, max=16, blank=True) individual_guess_3 = models.IntegerField(min=4, max=16, blank=True) signal_guess_initial = models.IntegerField(min=4, max=16, blank=True) signal_guess_updated = models.IntegerField(min=4, max=16, blank=True) action_guess_initial = models.IntegerField(min=4, max=16, blank=True) action_guess_updated = models.IntegerField(min=4, max=16, blank=True) action_known_choice = models.StringField(blank=True) # 'match' or 'own' # Legacy fields (kept but not populated) partner_id = models.IntegerField(blank=True) sat_out_this_round = models.BooleanField(initial=False) # Demographic form fields (for temporary storage before registry transfer) gender = models.StringField(blank=True) gender_self_describe = models.StringField(blank=True) tenure_years = models.StringField(blank=True) work_location = models.StringField(blank=True) # Likert form fields (temporary) likert_1 = models.IntegerField(blank=True) likert_2 = models.IntegerField(blank=True) likert_3 = models.IntegerField(blank=True) likert_4 = models.IntegerField(blank=True) likert_5 = models.IntegerField(blank=True) likert_6 = models.IntegerField(blank=True) likert_7 = models.IntegerField(blank=True) likert_8 = models.IntegerField(blank=True) likert_9 = models.IntegerField(blank=True) likert_10 = models.IntegerField(blank=True) likert_11 = models.IntegerField(blank=True) likert_12 = models.IntegerField(blank=True) likert_13 = models.IntegerField(blank=True) likert_14 = models.IntegerField(blank=True) likert_15 = models.IntegerField(blank=True) # ========== ExtraModels ========== class CompletedRound(ExtraModel): """ Stores historical round data from completed participants. This persists across sessions and is used to provide "partner" data for new participants in paired rounds. """ # Subsession link for oTree ExtraModel filtering - shared across all players subsession = models.Link(Subsession) # Link to the participant who completed this round participant_code = models.StringField() # participant.code for lookup participant_node_id = models.IntegerField() # org_chart_position participant_label = models.StringField() # first_name participant_role = models.StringField() # role_title # Round identification round_index = models.IntegerField() # Which of their rounds (1-6 for individual) round_type = models.StringField() # 'individual', 'signal', 'action', 'action-known' # Urn and draw data urn_red_balls = models.IntegerField() draws_json = models.LongStringField() # JSON array of draws # Guesses first_guess = models.IntegerField(blank=True) # Initial guess before seeing partner info updated_guess = models.IntegerField(blank=True) # Updated guess after seeing partner info (if paired) # Timestamp for ordering completed_at = models.StringField() # ISO timestamp class ParticipantRegistry(ExtraModel): """ Registry of all participants who have completed their experiment. Used to determine participant order and available partners. This also stores all round-independent data (Survey, Demographics, Practice draws). """ # Subsession link for oTree ExtraModel filtering subsession = models.Link(Subsession) participant_code = models.StringField() node_id = models.IntegerField() first_name = models.StringField() role_title = models.StringField() join_order = models.IntegerField() num_rounds = models.IntegerField() completed = models.BooleanField(initial=False) registered_at = models.StringField() # Practice/Demo Draw Info (Static) demo_urn_red = models.IntegerField(blank=True) demo_draws_json = models.LongStringField(blank=True) # Networking Survey (Static) advice_json = models.LongStringField(initial='[]', blank=True) friends_json = models.LongStringField(initial='[]', blank=True) insight_json = models.LongStringField(initial='[]', blank=True) # Demographics (Static) gender = models.StringField(blank=True) gender_self_describe = models.StringField(blank=True) tenure_years = models.StringField(blank=True) work_location = models.StringField(blank=True) # Likert Scales (Static) likert_1 = models.IntegerField(blank=True) likert_2 = models.IntegerField(blank=True) likert_3 = models.IntegerField(blank=True) likert_4 = models.IntegerField(blank=True) likert_5 = models.IntegerField(blank=True) likert_6 = models.IntegerField(blank=True) likert_7 = models.IntegerField(blank=True) likert_8 = models.IntegerField(blank=True) likert_9 = models.IntegerField(blank=True) likert_10 = models.IntegerField(blank=True) likert_11 = models.IntegerField(blank=True) likert_12 = models.IntegerField(blank=True) likert_13 = models.IntegerField(blank=True) likert_14 = models.IntegerField(blank=True) likert_15 = models.IntegerField(blank=True) # Partner Ratings (JSON) partner_ratings_json = models.LongStringField(initial='[]', blank=True) def custom_export(players): """Custom export for data stored in ParticipantRegistry (ExtraModel). oTree's standard exports focus on Player rows; the colleague survey and other participant-level data is stored in ParticipantRegistry and won't appear in all_apps_wide by default. """ players = list(players) if not players: return participant_codes = sorted({p.participant.code for p in players}) root_subsession = players[0].subsession.in_round(1) regs = ParticipantRegistry.filter(subsession=root_subsession) reg_by_code = {r.participant_code: r for r in regs} header = [ 'participant_code', 'node_id', 'first_name', 'role_title', 'join_order', 'num_rounds', 'completed', 'registered_at', 'demo_urn_red', 'demo_draws_json', 'advice_json', 'friends_json', 'insight_json', 'gender', 'gender_self_describe', 'tenure_years', 'work_location', 'likert_1', 'likert_2', 'likert_3', 'likert_4', 'likert_5', 'likert_6', 'likert_7', 'likert_8', 'likert_9', 'likert_10', 'likert_11', 'likert_12', 'likert_13', 'likert_14', 'likert_15', 'partner_ratings_json', ] yield header for code in participant_codes: reg = reg_by_code.get(code) yield [ code, getattr(reg, 'node_id', None), getattr(reg, 'first_name', ''), getattr(reg, 'role_title', ''), getattr(reg, 'join_order', None), getattr(reg, 'num_rounds', None), getattr(reg, 'completed', None), getattr(reg, 'registered_at', ''), getattr(reg, 'demo_urn_red', None), getattr(reg, 'demo_draws_json', ''), getattr(reg, 'advice_json', ''), getattr(reg, 'friends_json', ''), getattr(reg, 'insight_json', ''), getattr(reg, 'gender', ''), getattr(reg, 'gender_self_describe', ''), getattr(reg, 'tenure_years', ''), getattr(reg, 'work_location', ''), getattr(reg, 'likert_1', None), getattr(reg, 'likert_2', None), getattr(reg, 'likert_3', None), getattr(reg, 'likert_4', None), getattr(reg, 'likert_5', None), getattr(reg, 'likert_6', None), getattr(reg, 'likert_7', None), getattr(reg, 'likert_8', None), getattr(reg, 'likert_9', None), getattr(reg, 'likert_10', None), getattr(reg, 'likert_11', None), getattr(reg, 'likert_12', None), getattr(reg, 'likert_13', None), getattr(reg, 'likert_14', None), getattr(reg, 'likert_15', None), getattr(reg, 'partner_ratings_json', ''), ]