import json import datetime import time from sqlalchemy import Column as Column, ForeignKey from sqlalchemy.orm import relationship from sqlalchemy.sql import sqltypes as st from starlette.exceptions import HTTPException import otree.common import otree.database from otree.common import random_chars_8, ADMIN_SECRET_CODE import otree.constants from otree.database import MixinVars, CurrencyType, NoResultFound from otree.lookup import url_i_should_be_on, get_page_lookup import otree.channels.utils as channel_utils from otree import settings class Participant(MixinVars, otree.database.SSPPGModel): __tablename__ = 'otree_participant' session_id = Column(st.Integer, ForeignKey('otree_session.id', ondelete='CASCADE')) # getting integrityerror, so trying passive_deletes # https://stackoverflow.com/questions/5033547/sqlalchemy-cascade-delete session = relationship("Session", back_populates="pp_set") label = Column(st.String(100), nullable=True) id_in_session = Column(st.Integer, nullable=True) payoff = Column(CurrencyType, default=0) # status could be 'finished', 'dropped out', etc. Empty string means active/in progress status = Column(st.String(50), default='', nullable=False) # better to use a string so that it doesn't become unofficial API time_started_utc = Column(st.String(50), nullable=True) mturk_assignment_id = Column(st.String(50), nullable=True) mturk_worker_id = Column(st.String(50), nullable=True) _index_in_pages = Column(st.Integer, default=0, index=True) def _numeric_label(self): """the human-readable version.""" return 'P{}'.format(self.id_in_session) _monitor_note_json = Column(st.String(300), default='[]') code = Column( st.String(16), default=random_chars_8, # set non-nullable, until we make our CharField non-nullable nullable=False, # unique implies DB index unique=True, index=True, ) # useful when we don't want to load the whole session just to get the code _session_code = Column(st.String(16)) visited = Column( st.Boolean, default=False, index=True, ) # stores when the page was first visited _last_page_timestamp = Column(st.Integer, nullable=True) _last_request_timestamp = Column(st.Integer, nullable=True) is_on_wait_page = Column(st.Boolean, default=False) # these are both for the admin # In the changelist, simply call these "page" and "app" _current_page_name = Column(st.String(200), nullable=True) _current_app_name = Column(st.String(200), nullable=True) # doesn't actually hold any data. just used because we need # a column in the monitor table for the presence icon, # and the code currently expects an attribute _presence = '' # Column(st.String(1), default=True) # only to be displayed in the admin participants changelist _round_number = Column(st.Integer, nullable=True) _current_form_page_url = Column(st.String(500)) _max_page_index = Column( st.Integer, ) _SETATTR_NO_FIELD_HINT = ' You can define it in the PARTICIPANT_FIELDS setting.' _is_bot = Column(st.Boolean, default=False) # can't start with an underscore because used in template # can't end with underscore because it's a django field (fields.E001) is_browser_bot = Column(st.Boolean, default=False) _timeout_expiration_time = otree.database.FloatField() _timeout_page_index = Column( st.Integer, ) _waitpage_is_connected = Column(st.Boolean, default=False) _waitpage_tab_hidden = Column(st.Boolean, default=False) _gbat_page_index = Column( st.Integer, ) _gbat_grouped = Column( st.Boolean, ) def set_label(self, label): if not label: return if len(label) > 100: raise HTTPException( 404, f'participant_label is too long or malformed: {label}' ) self.label = label def _current_page_of_total(self): # don't put 'pages' because that causes wrapping which takes more space # since it's longer than the header return [self._index_in_pages, self._max_page_index] # because variables used in templates can't start with an underscore def current_page_(self): return f'{self._index_in_pages}/{self._max_page_index}' def get_players(self): """Used to calculate payoffs""" lst = [] app_sequence = self.session.config['app_sequence'] for app in app_sequence: models_module = otree.common.get_models_module(app) players = models_module.Player.objects_filter(participant=self).order_by( 'round_number' ) lst.extend(list(players)) return lst def get_player(self, app_name, round_number): models_module = otree.common.get_models_module(app_name) PlayerClass = getattr(models_module, 'Player') try: return PlayerClass.objects_get(participant=self, round_number=round_number) except NoResultFound: msg = f"No player found for the given app_name={repr(app_name)} and round_number={repr(round_number)}" raise Exception(msg) from None def _url_i_should_be_on(self): if not self.visited: return self._start_url() if self._index_in_pages <= self._max_page_index: return url_i_should_be_on( self.code, self._session_code, self._index_in_pages ) return '/OutOfRangeNotification/' + self.code def _start_url(self): return otree.common.participant_start_url(self.code) def payoff_in_real_world_currency(self): return self.payoff.to_real_world_currency(self.session) def payoff_plus_participation_fee(self): return self.session._get_payoff_plus_participation_fee(self.payoff) def _get_current_player(self): lookup = get_page_lookup(self._session_code, self._index_in_pages) models_module = otree.common.get_models_module(lookup.app_name) PlayerClass = getattr(models_module, 'Player') return PlayerClass.objects_get( participant=self, round_number=lookup.round_number ) def initialize(self, participant_label): """in a separate function so that we can call it individually, e.g. from advance_last_place_participants""" pp = self if pp._index_in_pages == 0: pp._index_in_pages = 1 pp.visited = True # participant.label might already have been set if not pp.label: pp.set_label(participant_label) # use UTC because daylight savings is not abandoned yet in EU # if anything, they will switch to permanent CEST (UTC+2) pp.time_started_utc = str(datetime.datetime.utcnow()) pp._last_page_timestamp = int(time.time()) from otree import common2 row = common2.TimeSpentRow( session_code=pp._session_code, participant_id_in_session=pp.id_in_session, participant_code=pp.code, page_index=0, app_name='', page_name='InitializeParticipant', epoch_time_completed=int(time.time()), round_number=1, timeout_happened=0, is_wait_page=0, ) common2.write_row_to_page_buffer(row) def _update_monitor_table(self): from otree import export channel_utils.sync_group_send( group=channel_utils.session_monitor_group_name(self._session_code), data=dict(rows=export.get_rows_for_monitor([self])), ) def _get_page_instance(self): if self._index_in_pages > self._max_page_index: return page = get_page_lookup( self._session_code, self._index_in_pages ).page_class.instantiate_without_request() page.set_attributes(self) return page def _submit_current_page(self): from otree.api import Page page = self._get_page_instance() if isinstance(page, Page): from starlette.datastructures import FormData page._form_data = FormData( { otree.constants.admin_secret_code: ADMIN_SECRET_CODE, otree.constants.timeout_happened: '1', } ) page.post() def _visit_current_page(self): """ we need the redirect logic because if it's from ensure_pages_visited, then the previous page was a wait page, so that will not handle redirects. """ # don't need to handle more redirects than that. for i in range(5): page = self._get_page_instance() if not page: return # note-to-self # it's possible that the slowest user is on a wait page, # especially if their browser is closed. # because they were waiting for another user who then # advanced past the wait page, but they were never # advanced themselves. resp = page.get() if not str(resp.status_code).startswith('3'): return