import logging import time from datetime import datetime from sqlalchemy import Column from sqlalchemy.orm import relationship from sqlalchemy.sql import sqltypes as st from starlette.responses import Response import otree.common import otree.constants import otree.database from otree import settings from otree.channels.utils import auto_advance_group from otree.common import ( random_chars_8, random_chars_join_code, get_admin_secret_code, get_builtin_constant, ) from otree.database import NoResultFound, MixinVars from otree.models_concrete import RoomToSession from otree.templating import get_template_name_if_exists from otree.templating.loader import TemplateLoadError logger = logging.getLogger('otree') ADMIN_SECRET_CODE = get_admin_secret_code() class Session(MixinVars, otree.database.SSPPGModel): __tablename__ = 'otree_session' config: dict = Column(otree.database._PickleField, default=dict) # should i also set cascade on all other models? # i should check what is deleted. pp_set = relationship( "Participant", back_populates="session", lazy='dynamic', cascade="all, delete-orphan", passive_deletes=True, ) # label of this session instance label = Column(st.String, nullable=True) code = Column( st.String(16), default=random_chars_8, nullable=False, unique=True, index=True ) mturk_HITId = Column(st.String(300), nullable=True) mturk_HITGroupId = Column(st.String(300), nullable=True) is_mturk = Column(st.Boolean, default=False) def mturk_num_workers(self): assert self.is_mturk return int(self.num_participants / settings.MTURK_NUM_PARTICIPANTS_MULTIPLE) mturk_use_sandbox = Column(st.Boolean, default=True) # use Float instead of DateTime because DateTime # is a pain to work with (e.g. naive vs aware datetime objects) # and there is no need here for DateTime mturk_expiration = Column(st.Float, nullable=True) mturk_qual_id = Column(st.String(50), default='') archived = Column(st.Boolean, default=False, index=True,) comment = Column(st.Text) _anonymous_code = Column( st.String(20), default=random_chars_join_code, nullable=False, index=True, ) is_demo = Column(st.Boolean, default=False) _admin_report_app_names = Column(st.Text, default='') _admin_report_num_rounds = Column(st.String(255), default='') num_participants = Column(st.Integer) _created = Column(st.DateTime, default=datetime.utcnow) def _created_readable(self): now = datetime.utcnow() delta = now - self._created days = delta.days seconds = delta.seconds if days > 1: return f'{days} days ago' if days == 1: return '1 day ago' num_hours = seconds // (60 * 60) if num_hours >= 1: return f'{num_hours} hours ago' return '< 1 hour ago' _SETATTR_NO_FIELD_HINT = ' You can define it in the SESSION_FIELDS setting.' def __unicode__(self): return self.code @property def participation_fee(self): '''This method is deprecated from public API, but still useful internally (like data export)''' return self.config['participation_fee'] @property def real_world_currency_per_point(self): '''This method is deprecated from public API, but still useful internally (like data export)''' return self.config['real_world_currency_per_point'] @property def use_browser_bots(self): return self.config.get('use_browser_bots', False) def mock_exogenous_data(self): ''' It's for any exogenous data: - participant labels (which are not passed in through REST API) - participant vars - session vars (if we enable that) ''' if self.config.get('mock_exogenous_data'): import shared_out as user_utils user_utils.mock_exogenous_data(self) def get_subsessions(self): lst = [] app_sequence = self.config['app_sequence'] for app in app_sequence: models_module = otree.common.get_models_module(app) subsessions = models_module.Subsession.objects_filter( session=self ).order_by('round_number') lst.extend(list(subsessions)) return lst def get_participants(self): return list(self.pp_set.order_by('id_in_session')) def mturk_worker_url(self): # different HITs # get the same preview page, because they are lumped into the same # "hit group". This is not documented, but it seems HITs are lumped # if a certain subset of properties are the same: # https://forums.aws.amazon.com/message.jspa?messageID=597622#597622 # this seems like the correct design; the only case where this will # not work is if the HIT was deleted from the server, but in that case, # the HIT itself should be canceled. # 2018-06-04: # the format seems to have changed to this: # https://worker.mturk.com/projects/{group_id}/tasks?ref=w_pl_prvw # but the old format still works. # it seems I can't replace groupId by hitID, which i would like to do # because it's more precise. subdomain = "workersandbox" if self.mturk_use_sandbox else 'www' return "https://{}.mturk.com/mturk/preview?groupId={}".format( subdomain, self.mturk_HITGroupId ) def mturk_is_expired(self): # self.mturk_expiration is offset-aware, so therefore we must compare # it against an offset-aware value. return self.mturk_expiration and self.mturk_expiration < time.time() def mturk_is_active(self): return self.mturk_HITId and not self.mturk_is_expired() def advance_last_place_participants(self): """the problem with using the test client to make get/post requests is (1) this request already has the global asyncio.lock (2) there are apparently some issues with async/await and event loops. """ participants = self.get_participants() last_place_page_index = min([p._index_in_pages for p in participants]) # max 20 at a time because: # - you could run into Heroku 30s timeout. # - in a huge session, if your finger slips you could mess up the whole session # - less problems with responsiveness, needing to gray out the button, etc. # - we could make a confirmation dialog on the client side, but JS less reliable, # slows people down more. # this is more proportional to effort. last_place_participants = [ p for p in participants if p._index_in_pages == last_place_page_index ][: otree.constants.ADVANCE_SLOWEST_BATCH_SIZE] if last_place_page_index == 0: for p in last_place_participants: p.initialize(None) p._visit_current_page() else: for p in last_place_participants: p._submit_current_page() # need to do this to update the monitor table, set any timeouts, etc. p._visit_current_page() # 2020-12-20: this is needed. # do the auto-advancing here, # rather than in increment_index_in_pages, # because it's only needed here. otree.channels.utils.sync_group_send( group=auto_advance_group(p.code), data={'auto_advanced': True} ) def get_room(self): from otree.room import ROOM_DICT try: room_name = RoomToSession.objects_get(session=self).room_name return ROOM_DICT[room_name] except NoResultFound: return None def _get_payoff_plus_participation_fee(self, payoff): '''For a participant who has the given payoff, return their payoff_plus_participation_fee Useful to define it here, for data export ''' return self.config['participation_fee'] + payoff.to_real_world_currency(self) def _set_admin_report_app_names(self): admin_report_app_names = [] num_rounds_list = [] for app_name in self.config['app_sequence']: models_module = otree.common.get_models_module(app_name) try: get_template_name_if_exists( [f'{app_name}/admin_report.html', f'{app_name}/AdminReport.html'] ) except TemplateLoadError: pass else: admin_report_app_names.append(app_name) num_rounds_list.append(get_builtin_constant(app_name, 'num_rounds')) self._admin_report_app_names = ';'.join(admin_report_app_names) self._admin_report_num_rounds = ';'.join(str(n) for n in num_rounds_list) def _admin_report_apps(self): return self._admin_report_app_names.split(';') def _admin_report_num_rounds_list(self): return [int(num) for num in self._admin_report_num_rounds.split(';')] def has_admin_report(self): return bool(self._admin_report_app_names)