class Validator(Base, db.Model): id = db.Column(db.Integer, primary_key=True) _question_id = db.Column(db.Integer, db.ForeignKey('question.id')) index = db.Column(db.Integer) validate = db.Column(FunctionType) def __init__(self, question=None, index=None, validate=None): self.set_question(question, index) self.validate = validate super().__init__() def set_question(self, question, index=None): self._set_parent(question, index, 'question', 'validators')
class Payoff(FunctionMixin, Base, db.Model): id = db.Column(db.Integer, primary_key=True) game_id = db.Column(db.Integer, db.ForeignKey('game.id')) @property def parent(self): return self.game @parent.setter def parent(self, game): self.game = game @Base.init('Payoff') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs)
class Strategy(FunctionMixin, Base, db.Model): id = db.Column(db.Integer, primary_key=True) player_id = db.Column(db.Integer, db.ForeignKey('player.id')) @property def parent(self): return self.player @parent.setter def parent(self, player): self.player = player @Base.init('Strategy') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs)
class Player(Base, db.Model): """Player model Players relate to a `Strategy` function, which is called each round by the `Game` to output an `action`. """ id = db.Column(db.Integer, primary_key=True) game_id = db.Column(db.Integer, db.ForeignKey('game.id')) strategy = db.relationship('Strategy', backref='player', uselist=False) index = db.Column(db.Integer) name = db.Column(db.String) actions = db.Column(MutableListType) payoffs = db.Column(MutableListType) cum_payoffs = db.Column(MutableListType) @Base.init('Player') def __init__(self, game=None, *args, **kwargs): self.actions, self.payoffs, self.cum_payoffs = [], [], [] super().__init__() return {'game': game, **kwargs} def rewind(self, rounds=1): del self.actions[-rounds:] del self.payoffs[-rounds:] del self.cum_payoffs[-rounds:]
class Branch(BranchingBase, db.Model): id = db.Column(db.Integer, primary_key=True) _part_id = db.Column(db.Integer, db.ForeignKey('participant.id')) _part_head_id = db.Column(db.Integer, db.ForeignKey('participant.id')) index = db.Column(db.Integer) _origin_branch_id = db.Column(db.Integer, db.ForeignKey('branch.id')) origin_branch = db.relationship('Branch', back_populates='next_branch', uselist=False, foreign_keys='Branch._origin_branch_id') _next_branch_id = db.Column(db.Integer, db.ForeignKey('branch.id')) next_branch = db.relationship('Branch', back_populates='origin_branch', uselist=False, remote_side=id, foreign_keys='Branch._next_branch_id') _origin_page_id = db.Column(db.Integer, db.ForeignKey('page.id')) origin_page = db.relationship('Page', back_populates='next_branch', foreign_keys='Branch._origin_page_id') pages = db.relationship('Page', backref='branch', order_by='Page.index', collection_class=ordering_list('index'), foreign_keys='Page._branch_id') @property def start_page(self): """Return the start of the page queue""" return self.pages[0] if self.pages else None current_page = db.relationship('Page', uselist=False, foreign_keys='Page._branch_head_id') embedded = db.relationship('Embedded', backref='branch', order_by='Embedded.index', collection_class=ordering_list('index')) @property def questions(self): page_questions = [ q for p in self.pages for q in p.questions + [p.timer] ] return page_questions + self.embedded navigate = db.Column(FunctionType) _isroot = db.Column(db.Boolean) def __init__(self, pages=[], embedded=[], navigate=None): self.pages = pages self.embedded = embedded self.navigate = navigate self._isroot = False super().__init__() def _forward(self): """Advance forward to the next page in the queue""" if self.current_page is None: return new_head_index = self.current_page.index + 1 if new_head_index == len(self.pages): self.current_page = None return self.current_page = self.pages[new_head_index] def _back(self): """Return to previous page in queue""" if not self.pages: return if self.current_page is None: self.current_page = self.pages[-1] return new_head_index = self.current_page.index - 1 self.current_page = self.pages[new_head_index] def view_nav(self): """Print page queue for debugging purposes""" INDENT = ' ' HEAD_PART = '<== head branch of participant' HEAD_BRANCH = '<== head page of branch' indent = INDENT * (0 if self.index is None else self.index) head_part = HEAD_PART if self == self.part.current_branch else '' print(indent, self, head_part) [p.view_nav(indent) for p in self.pages] head_branch = HEAD_BRANCH if None == self.current_page else '' print(indent, None, head_branch) if self.next_branch in self.part.branch_stack: self.next_branch.view_nav()
class Game(Base, db.Model): """Game model A `Game` contains a list of `Player`s and a `Payoff` function. The game is played for a number of `rounds`. Each round, players choose an `action` as the output of their `Strategy` function. The payoff function then returns a list of payoffs, where payoffs are ordered by player index. Games display players' actions, stage payoffs, and cumulative payoffs with the `html_table` method. """ id = db.Column(db.Integer, primary_key=True) description = db.Column(db.Text) rounds = db.Column(db.Integer) players = db.relationship('Player', backref='game', order_by='Player.index', collection_class=ordering_list('index')) payoff_function = db.relationship('Payoff', backref='game', uselist=False) @property def actions(self): return {p.name: p.actions for p in self.players} @property def payoffs(self): return {p.name: p.payoffs for p in self.players} @property def cum_payoffs(self): return {p.name: p.cum_payoffs for p in self.players} @Base.init('Game') def __init__(self, *args, **kwargs): self.rounds = 0 super().__init__() return kwargs def play(self, rounds=1): [self._play() for i in range(rounds)] def _play(self): actions = [p.strategy() for p in self.players] [p.actions.append(a) for p, a in zip(self.players, actions)] payoffs = self.payoff_function() for p in self.players: payoff = payoffs[p.index] p.payoffs.append(payoff) cum_payoff = p.cum_payoffs[-1] if p.cum_payoffs else 0 p.cum_payoffs.append(cum_payoff + payoff) self.rounds += 1 def rewind(self, rounds=1): [p.rewind(rounds) for p in self.players] self.rounds -= rounds def html_table(self, rounds=None): self._table_rounds = rounds or self.rounds game_table = read_file('game_table.html') return game_table.format(game=self) @property def _players(self): player_col = read_file('player_col.html') return ''.join([player_col.format(name=p.name) for p in self.players]) @property def _stats_header(self): stats_header = read_file('stats_header.html') return ''.join([stats_header for i in self.players]) @property def _stats(self): round_stats = read_file('round_stats.html') stats = '' for i in range(self._table_rounds): self._round = i stats += round_stats.format(game=self) return stats @property def _player_stats(self): player_stats = read_file('player_stats.html') return ''.join([ player_stats.format(action=p.actions[self._round], payoff=p.payoffs[self._round], cum_payoff=p.cum_payoffs[self._round]) for p in self.players ])
class Question(MutableModelBase, CompileBase, db.Model): id = db.Column(db.Integer, primary_key=True) type = db.Column(db.String(50)) __mapper_args__ = { 'polymorphic_identity': 'question', 'polymorphic_on': type } _page_id = db.Column(db.Integer, db.ForeignKey('page.id')) _page_timer_id = db.Column(db.Integer, db.ForeignKey('page.id')) index = db.Column(db.Integer) choice_div_classes = db.Column(MutableListType) choice_input_type = db.Column(db.String(50)) choices = db.relationship('Choice', backref='question', order_by='Choice.index', collection_class=ordering_list('index'), foreign_keys='Choice._question_id') selected_choices = db.relationship( 'Choice', order_by='Choice._selected_index', collection_class=ordering_list('_selected_index'), foreign_keys='Choice._selected_id') nonselected_choices = db.relationship( 'Choice', order_by='Choice._nonselected_index', collection_class=ordering_list('_nonselected_index'), foreign_keys='Choice._nonselected_id') validators = db.relationship('Validator', backref='question', order_by='Validator.index', collection_class=ordering_list('index')) all_rows = db.Column(db.Boolean) data = db.Column(MutableType) _default = db.Column(MutableType) div_classes = db.Column(MutableListType) error = db.Column(db.Text) init_default = db.Column(MutableType) order = db.Column(db.Integer) response = db.Column(MutableType) text = db.Column(db.Text) var = db.Column(db.Text) compile = db.Column(FunctionType) debug = db.Column(FunctionType) post = db.Column(FunctionType) @property def default(self): return self._default @default.setter def default(self, default): """Set initial default the first time default is set""" if self.init_default is None: self.init_default = default self._default = default def __init__(self, page=None, index=None, validators=[], choice_div_classes=[], choice_input_type=None, choices=[], all_rows=False, data=None, default=None, div_classes=[], text=None, var=None, compile=None, debug=None, post=None): self.set_page(page, index) self.choice_div_classes = choice_div_classes self.choice_input_type = choice_input_type self.choices = choices self.validators = validators self.all_rows = all_rows self.data = data self.default = default self.div_classes = div_classes or current_app.question_div_classes self.text = text self.var = var self.compile = compile or current_app.question_compile self.debug = debug or current_app.question_debug self.post = post or current_app.question_post super().__init__() """API methods""" def set_page(self, page, index=None): self._set_parent(page, index, 'page', 'questions') def reset_default(self): self.default = self.init_default """Methods executed during study""" def compile_html(self, content=''): """HTML compiler""" div_classes = self.get_div_classes() label = self.get_label() return DIV.format(id=self.model_id, classes=div_classes, label=label, content=content) def get_div_classes(self): """Get question <div> classes Add the error class if the response was invalid. """ div_classes = ' '.join(self.div_classes) if self.error is not None: return div_clases + ' error' return div_classes def get_label(self): """Get the question label""" error = self.error error = '' if error is None else ERROR.format(error=error) text = self.text if self.text is not None else '' return LABEL.format(id=self.model_id, text=error + text) def record_response(self, response): self.response = response def validate(self): """Validate Participant response Keep the error message associated with the first failed Validator. """ for v in self.validators: self.error = v.validate(object=self) if self.error is not None: return False return True def record_data(self): self.data = self.response def pack_data(self, data=None): """Pack data for storing in DataStore Note: <var>Index is the index of the object; its order within its Branch, Page, or Question. <var>Order is the order of the Question relative to other Questions with the same variable. The optional `data` argument is prepacked data from the question type. """ if self.var is None: return {} data = {self.var: self.data} if data is None else data if not self.all_rows: data[self.var + 'Order'] = self.order if self.index is not None: data[self.var + 'Index'] = self.index for c in self.choices: if c.label is not None: data[''.join([self.var, c.label, 'Index'])] = c.index return data
class Choice(CompileBase, db.Model): id = db.Column(db.Integer, primary_key=True) _question_id = db.Column(db.Integer, db.ForeignKey('question.id')) index = db.Column(db.Integer) _selected_id = db.Column(db.Integer, db.ForeignKey('question.id')) _selected_index = db.Column(db.Integer) _nonselected_id = db.Column(db.Integer, db.ForeignKey('question.id')) _nonselected_index = db.Column(db.Integer) text = db.Column(db.Text) label = db.Column(db.Text) value = db.Column(MutableType) debug = db.Column(FunctionType) def __init__( self, question=None, index=None, text=None, label=None, value=None, debug=None): self.set_question(question, index) self.set_all(text) self.value = value if value is not None else self.value self.label = label if label is not None else self.label self.debug = debug super().__init__() def set_question(self, question, index=None): self._set_parent(question, index, 'question', 'choices') def set_all(self, text): """Set text, value, and label to the same value""" self.text = self.label = self.value = text def compile_html(self): question=self.question classes = ' '.join(question.choice_div_classes) checked = 'checked' if self.is_default() else '' input = INPUT.format( cid=self.model_id, qid=question.model_id, type=question.choice_input_type, checked=checked ) text = self.text or '' label = LABEL.format(cid=self.model_id, text=text) return DIV.format(classes=classes, input=input, label=label) def is_default(self): """Indicate if self is a default choice Question default is assumed to a be a choice or list of choices. """ default = self.question.default if isinstance(default, list): return self in default return self == default
class Page(BranchingBase, CompileBase, db.Model): id = db.Column(db.Integer, primary_key=True) @property def part(self): return self.branch.part if self.branch is not None else None _branch_id = db.Column(db.Integer, db.ForeignKey('branch.id')) _branch_head_id = db.Column(db.Integer, db.ForeignKey('branch.id')) index = db.Column(db.Integer) next_branch = db.relationship('Branch', back_populates='origin_page', uselist=False, foreign_keys='Branch._origin_page_id') _back_to_id = db.Column(db.Integer, db.ForeignKey('page.id')) back_to = db.relationship('Page', uselist=False, foreign_keys='Page._back_to_id') _forward_to_id = db.Column(db.Integer, db.ForeignKey('page.id')) forward_to = db.relationship('Page', uselist=False, foreign_keys='Page._forward_to_id') _navbar_id = db.Column(db.Integer, db.ForeignKey('navbar.id')) questions = db.relationship('Question', backref='page', order_by='Question.index', collection_class=ordering_list('index'), foreign_keys='Question._page_id') start_time = db.Column(db.DateTime) timer = db.relationship('Question', uselist=False, foreign_keys='Question._page_timer_id') _back = db.Column(db.Boolean) back_button = db.Column(MarkupType) css = db.Column(MutableListType) _direction_from = db.Column(db.String(8)) _direction_to = db.Column(db.String(8)) _forward = db.Column(db.Boolean) forward_button = db.Column(MarkupType) js = db.Column(MutableListType) question_html = db.Column(MarkupType) survey_template = db.Column(db.Text) terminal = db.Column(db.Boolean) view_template = db.Column(db.Text) @property def back(self): return self._back and not self.first_page() @back.setter def back(self, back): self._back = back @property def direction_from(self): return self._direction_from @direction_from.setter def direction_from(self, value): assert value in DIRECTIONS, ( 'Direction must be one of: {}'.format(DIRECTIONS)) self._direction_from = value @property def direction_to(self): return self._direction_to @direction_to.setter def direction_to(self, value): assert value in DIRECTIONS, ( 'Direction must be one of: {}'.format(DIRECTIONS)) self._direction_to = value @property def forward(self): return self._forward and not self.terminal @forward.setter def forward(self, forward): self._forward = forward compile = db.Column(FunctionType) debug = db.Column(FunctionType) navigate = db.Column(FunctionType) post = db.Column(FunctionType) def __init__(self, branch=None, index=None, back_to=None, forward_to=None, nav=None, questions=[], timer_var=None, all_rows=False, back=None, back_button=None, css=None, forward=True, forward_button=None, js=None, survey_template=None, terminal=False, view_template=None, compile=None, debug=None, navigate=None, post=None): self.set_branch(branch, index) self.back_to = back_to self.forward_to = forward_to self.nav = nav or current_app.nav self.questions = questions self.timer = Question(all_rows=all_rows, data=0, var=timer_var) self.back = back if back is not None else current_app.back self.back_button = back_button or current_app.back_button self.css = css or current_app.css self.forward = forward if forward is not None else current_app.forward self.forward_button = forward_button or current_app.forward_button self.js = js or current_app.js self.survey_template = survey_template or current_app.survey_template self.terminal = terminal self.view_template = view_template or current_app.view_template self.compile = compile or current_app.page_compile self.debug = debug or current_app.page_debug self.navigate = navigate self.post = post or current_app.page_post super().__init__() """API methods""" def set_branch(self, branch, index=None): self._set_parent(branch, index, 'branch', 'pages') def is_blank(self): return all([q.response is None for q in self.questions]) def reset_compile(self): compile = default_compile def reset_default(self): [q.reset_default() for q in self.questions] def reset_post(self): post = default_post def reset_timer(self): self.timer.data = 0 def is_valid(self): return all([q.error is None for q in self.questions]) def first_page(self): """Indicate that this is the first Page in the experiment""" return (self.branch is not None and self.branch._isroot and self.index == 0) """Methods executed during study""" def compile_html(self, recompile=True): """Compile question html""" if self.question_html is None or recompile: self.compile(object=self) self.question_html = Markup(''.join( [q.compile_html() for q in self.questions])) self.start_time = datetime.utcnow() return self.render(render_template(self.survey_template, page=self)) def _submit(self): """Operations executed on page submission 1. Record responses 2. If attempting to navigate backward, there is nothing more to do 3. If attempting to navigate forward, check for valid responses 4. If responses are invalid, return 5. Record data 6. Run post function """ self._update_timer() self.direction_from = request.form['direction'] [ q.record_response(request.form.getlist(q.model_id)) for q in self.questions ] if self.direction_from == 'back': return 'back' if not all([q.validate() for q in self.questions]): self.direction_from = 'invalid' return 'invalid' [q.record_data() for q in self.questions] self.post(object=self) # self.direction_from is 'forward' unless changed in post function return self.direction_from def _update_timer(self): if self.start_time is None: self.start_time = datetime.utcnow() delta = (datetime.utcnow() - self.start_time).total_seconds() self.timer.data += delta def view_nav(self, indent): """Print self and next branch for debugging purposes""" HEAD_PART = '<== head page of participant' HEAD_BRANCH = '<== head page of branch' head_part = HEAD_PART if self == self.part.current_page else '' head_branch = HEAD_BRANCH if self == self.branch.current_page else '' print(indent, self, head_branch, head_part) if self.next_branch in self.part.branch_stack: self.next_branch.view_nav()