import importlib import json import logging import os import time from typing import Optional import otree.common2 import vanilla from django.conf import settings from django.core import signals from django.core.exceptions import PermissionDenied, SuspiciousOperation from django.core.handlers.exception import handle_uncaught_exception from django.http import ( HttpResponseRedirect, Http404, HttpResponseForbidden, HttpResponseNotFound, ) from django.http.multipartparser import MultiPartParserError from django.shortcuts import get_object_or_404 from django.template.response import TemplateResponse from django.urls import get_resolver, get_urlconf from django.urls import resolve from django.utils.decorators import method_decorator from django.utils.translation import ugettext as _, ugettext_lazy from django.views.decorators.cache import never_cache, cache_control import django.forms.models from django.views.decorators.csrf import csrf_exempt import otree.channels.utils as channel_utils import otree.common import otree.constants from otree.db import idmap import otree.forms import otree.models import otree.tasks from otree.bots.bot import bot_prettify_post_data, ExpectError from otree.common import ( get_app_label_from_import_path, get_dotted_name, get_admin_secret_code, DebugTable, BotError, ResponseForException, ) from otree.lookup import get_min_idx_for_app, get_page_lookup from otree.models import Participant, Session, BaseGroup, BaseSubsession from otree.models_concrete import ( CompletedSubsessionWaitPage, CompletedGroupWaitPage, CompletedGBATWaitPage, UndefinedFormModel, ) from otree import export # this is an expensive import import otree.bots.browser as browser_bots logger = logging.getLogger(__name__) UNHANDLED_EXCEPTIONS = ( Http404, PermissionDenied, MultiPartParserError, SuspiciousOperation, SystemExit, ) def response_for_exception(request, exc): '''simplified from Django 1.11 source. The difference is that we use the exception that was passed in, rather than referencing sys.exc_info(), which gives us the ResponseForException the original exception was wrapped in, which we don't want to show to users. ''' from django.utils.log import log_response # expensive import if isinstance(exc, UNHANDLED_EXCEPTIONS): '''copied from Django source, but i don't think these exceptions will actually occur.''' raise exc signals.got_request_exception.send(sender=None, request=request) exc_info = (type(exc), exc, exc.__traceback__) response = handle_uncaught_exception(request, get_resolver(get_urlconf()), exc_info) log_response( '%s: %s', response.reason_phrase, request.path, response=response, request=request, exc_info=exc, ) if settings.DEBUG: response.content = response.content.split(b'
')[0] # Force a TemplateResponse to be rendered. if not getattr(response, 'is_rendered', True) and callable( getattr(response, 'render', None) ): response = response.render() return response ADMIN_SECRET_CODE = get_admin_secret_code() def get_view_from_url(url): view_func = resolve(url).func module = importlib.import_module(view_func.__module__) Page = getattr(module, view_func.__name__) return Page BOT_COMPLETE_HTML_MESSAGE = ''' Bot completed Bot completed ''' class FormPageOrInGameWaitPage(vanilla.View): """ View that manages its position in the group sequence. for both players and experimenters """ template_name = None is_debug = settings.DEBUG def inner_dispatch(self): '''inner dispatch function''' raise NotImplementedError() def get_template_names(self): raise NotImplementedError() @classmethod def url_pattern(cls, name_in_url): p = r'^p/(?P\w+)/{}/{}/(?P\d+)/$'.format( name_in_url, cls.__name__ ) return p @classmethod def get_url(cls, participant_code, name_in_url, page_index): '''need this because reverse() is too slow in create_session''' return r'/p/{pcode}/{name_in_url}/{ClassName}/{page_index}/'.format( pcode=participant_code, name_in_url=name_in_url, ClassName=cls.__name__, page_index=page_index, ) @classmethod def url_name(cls): '''using dots seems not to work''' return get_dotted_name(cls).replace('.', '-') def _redirect_to_page_the_user_should_be_on(self): return HttpResponseRedirect(self.participant._url_i_should_be_on()) @method_decorator(csrf_exempt) @method_decorator(never_cache) @method_decorator( cache_control(must_revalidate=True, max_age=0, no_cache=True, no_store=True) ) def dispatch(self, request, participant_code, **kwargs): with idmap.use_cache(): try: participant = Participant.objects.get(code=participant_code) except Participant.DoesNotExist: msg = ( "This user ({}) does not exist in the database. " "Maybe the database was reset." ).format(participant_code) return HttpResponseNotFound(msg) # if the player tried to skip past a part of the subsession # (e.g. by typing in a future URL) # or if they hit the back button to a previous subsession # in the sequence. url_should_be_on = participant._url_i_should_be_on() if not self.request.path == url_should_be_on: return HttpResponseRedirect(url_should_be_on) self.set_attributes(participant) try: response = self.inner_dispatch() # need to render the response before saving objects, # because the template might call a method that modifies # player/group/etc. if hasattr(response, 'render'): response.render() except (ResponseForException, ExpectError) as exc: response = response_for_exception( self.request, exc.__cause__ or exc.__context__ ) except Exception as exc: # this is still necessary, e.g. if an attribute on the page # is invalid, like form_fields, form_model, etc. response = response_for_exception(self.request, exc) return response def get_context_data(self, **context): context.update( view=self, object=getattr(self, 'object', None), player=self.player, group=self.group, subsession=self.subsession, session=self.session, participant=self.participant, Constants=self._Constants, timer_text=getattr(self, 'timer_text', None), ) views_module = otree.common.get_pages_module( self.subsession._meta.app_config.name ) if hasattr(views_module, 'vars_for_all_templates'): vars_for_template = views_module.vars_for_all_templates(self) else: vars_for_template = {} try: user_vars = self.vars_for_template() context['js_vars'] = self.js_vars() except: raise ResponseForException vars_for_template.update(user_vars or {}) context.update(vars_for_template) if settings.DEBUG: self.debug_tables = self._get_debug_tables(vars_for_template) return context def render_to_response(self, context): """ Given a context dictionary, returns an HTTP response. """ return TemplateResponse( request=self.request, template=self.get_template_names(), context=context ) def vars_for_template(self): return {} def js_vars(self): return {} def _get_debug_tables(self, vars_for_template): tables = [] if vars_for_template: # use repr() so that we can distinguish strings from numbers # and can see currency types, etc. items = [(k, repr(v)) for (k, v) in vars_for_template.items()] rows = sorted(items) tables.append(DebugTable(title='vars_for_template', rows=rows)) player = self.player participant = self.participant basic_info_table = DebugTable( title='Basic info', rows=[ ('ID in group', player.id_in_group), ('Group', player.group_id), ('Round number', player.round_number), ('Participant', participant._numeric_label()), ('Participant label', participant.label or ''), ('Session code', participant._session_code), ], ) tables.append(basic_info_table) return tables def _is_displayed(self): try: return self.is_displayed() except: raise ResponseForException @property def group(self) -> BaseGroup: '''can't cache self._group_pk because group can change''' return self.player.group @property def subsession(self) -> BaseSubsession: '''so that it doesn't rely on player''' # this goes through idmap cache, so no perf hit return self.SubsessionClass.objects.get(pk=self._subsession_pk) @property def session(self) -> Session: return Session.objects.get(pk=self._session_pk) def set_attributes(self, participant): lookup = get_page_lookup(participant._session_code, participant._index_in_pages) self._lookup = lookup app_name = lookup.app_name models_module = otree.common.get_models_module(app_name) self._Constants = models_module.Constants self.PlayerClass = getattr(models_module, 'Player') self.GroupClass = getattr(models_module, 'Group') self.SubsessionClass = getattr(models_module, 'Subsession') self.player = self.PlayerClass.objects.get( participant=participant, round_number=lookup.round_number ) self._subsession_pk = lookup.subsession_id self.round_number = lookup.round_number self._session_pk = lookup.session_pk # simpler if we set it directly so that we can do tests without idmap cache self._participant_pk = participant.pk # setting it directly makes testing easier (tests dont need to use cache) self.participant: Participant = participant # it's already validated that participant is on right page self._index_in_pages = participant._index_in_pages # for the participant changelist participant._current_app_name = app_name participant._current_page_name = self.__class__.__name__ participant._last_request_timestamp = time.time() participant._round_number = lookup.round_number self._is_frozen = True def set_attributes_waitpage_clone(self, *, original_view: 'WaitPage'): '''put it here so it can be compared with set_attributes... but this is really just a method on wait pages''' # make a clean copy for AAPA # self.player and self.participant etc are undefined # and no objects are cached inside it # and it doesn't affect the current instance self._Constants = original_view._Constants self.GroupClass = original_view.GroupClass self.SubsessionClass = original_view.SubsessionClass self._subsession_pk = original_view._subsession_pk self._session_pk = original_view._session_pk self.round_number = original_view.round_number def _increment_index_in_pages(self): # when is this not the case? assert self._index_in_pages == self.participant._index_in_pages # we should allow a user to move beyond the last page if it's mturk # also in general maybe we should show the 'out of sequence' page # we skip any page that is a sequence page where is_displayed # evaluates to False to eliminate unnecessary redirection page_index_to_skip_to = self._get_next_page_index_if_skipping_apps() is_skipping_apps = bool(page_index_to_skip_to) for page_index in range( # go to max_page_index+2 because range() skips the last index # and it's possible to go to max_page_index + 1 (OutOfRange) self._index_in_pages + 1, self.participant._max_page_index + 2, ): self.participant._index_in_pages = page_index if page_index == self.participant._max_page_index + 1: # break and go to OutOfRangeNotification break if is_skipping_apps and page_index == page_index_to_skip_to: break url = self.participant._url_i_should_be_on() Page = get_view_from_url(url) page: FormPageOrInGameWaitPage = Page() page.set_attributes(self.participant) if not is_skipping_apps: if page._lookup.is_first_in_round: # we have moved to a new round. page.player.start() if page._is_displayed(): break # if it's a wait page, record that they visited # but don't run after_all_players_arrive if isinstance(page, WaitPage): if page.group_by_arrival_time: # keep looping # if 1 participant can skip the page, # then all other participants should skip it also, # as described in the docs # so there is no need to mark as complete. continue # save the participant, because tally_unvisited # queries index_in_pages directly from the DB self.participant.save() is_last, someone_waiting = page._tally_unvisited() if is_last and someone_waiting: # the notify code uses self.request.build_absolute_uri # to send the URL to the timeoutworker page.request = self.request page._run_aapa_and_notify(page._group_or_subsession) def is_displayed(self): return True def _update_monitor_table(self): participant = self.participant channel_utils.sync_group_send_wrapper( type='monitor_table_delta', group=channel_utils.session_monitor_group_name(participant._session_code), event=dict(rows=export.get_rows_for_monitor([participant])), ) def _get_next_page_index_if_skipping_apps(self): # don't run it if the page is not displayed, because: # (1) it's consistent with other functions like before_next_page, vars_for_template # (2) then when we do # a lookahead skipping pages, we would need to check each page if it # has app_after_this_page defined, then set attributes and run it. # what if we are already skipping to a future app, then another page # has app_after_this_page? does it override the first one? if not self._is_displayed(): return app_after_this_page = getattr(self, 'app_after_this_page', None) if not app_after_this_page: return current_app = self.participant._current_app_name app_sequence = self.session.config['app_sequence'] current_app_index = app_sequence.index(current_app) upcoming_apps = app_sequence[current_app_index + 1 :] app_to_skip_to = app_after_this_page(upcoming_apps) if app_to_skip_to: if app_to_skip_to not in upcoming_apps: msg = f'"{app_to_skip_to}" is not in the upcoming_apps list' raise InvalidAppError(msg) return get_min_idx_for_app(self.participant._session_code, app_to_skip_to) def _record_page_completion_time(self): now = int(time.time()) participant = self.participant session_code = participant._session_code otree.common2.make_page_completion_row( view=self, app_name=self.player._meta.app_config.name, participant__id_in_session=participant.id_in_session, participant__code=participant.code, session_code=session_code, is_wait_page=0, ) participant._last_page_timestamp = now _is_frozen = False _setattr_whitelist = { '_is_frozen', 'object', 'form', 'timeout_happened', # i should send some of these through context '_remaining_timeout_seconds', 'first_field_with_errors', 'other_fields_with_errors', 'debug_tables', '_round_number', 'request', # this is just used in a test case mock. } def __setattr__(self, attr: str, value): if self._is_frozen and not attr in self._setattr_whitelist: msg = ( 'You set the attribute "{}" on the page {}. ' 'Setting attributes on page instances is not permitted. ' ).format(attr, self.__class__.__name__) raise AttributeError(msg) else: # super() is a bit slower but only gets run during __init__ super().__setattr__(attr, value) def live_url(self): return channel_utils.live_path( participant_code=self.participant.code, page_name=type(self).__name__, page_index=self._index_in_pages, session_code=self.participant._session_code, live_method_name=self.live_method, ) live_method = '' class Page(FormPageOrInGameWaitPage): # if a model is not specified, use empty "StubModel" form_model = UndefinedFormModel form_fields = [] def inner_dispatch(self): if self.request.method == 'POST': return self.post() return self.get() def browser_bot_stuff(self, response: TemplateResponse): if self.participant.is_browser_bot: if hasattr(response, 'render'): response.render() browser_bots.set_attributes( participant_code=self.participant.code, request_path=self.request.path, html=response.content.decode('utf-8'), ) has_next_submission = browser_bots.enqueue_next_post_data( participant_code=self.participant.code ) if has_next_submission: # this doesn't work because we also would need to do this on OutOfRange page. # sometimes the player submits the last page, especially during development. # if self._index_in_pages == self.participant._max_page_index: auto_submit_js = ''' ''' response.content += auto_submit_js.encode('utf8') else: browser_bots.send_completion_message( session_code=self.participant._session_code, participant_code=self.participant.code, ) def get(self): if not self._is_displayed(): self._increment_index_in_pages() return self._redirect_to_page_the_user_should_be_on() # this needs to be set AFTER scheduling submit_expired_url, # to prevent race conditions. # see that function for an explanation. self.participant._current_form_page_url = self.request.path self.object = self.get_object() self._update_monitor_table() # 2020-07-10: maybe we should call vars_for_template before instantiating the form # so that you can set initial value for a field in vars_for_template? form = self.get_form(instance=self.object) context = self.get_context_data(form=form) response = self.render_to_response(context) self.browser_bot_stuff(response) return response def get_template_names(self): if self.template_name is not None: return [self.template_name] return [ '{}/{}.html'.format( get_app_label_from_import_path(self.__module__), self.__class__.__name__ ) ] def get_form_fields(self): return self.form_fields def _get_form_model(self): form_model = self.form_model if isinstance(form_model, str): if form_model == 'player': return self.PlayerClass if form_model == 'group': return self.GroupClass msg = ( "'{}' is an invalid value for form_model. " "Try 'player' or 'group' instead.".format(form_model) ) raise ValueError(msg) return form_model def get_form_class(self): try: fields = self.get_form_fields() except: raise ResponseForException form_model = self._get_form_model() if form_model is UndefinedFormModel and fields: msg = 'Page "{}" defined form_fields but not form_model'.format( self.__class__.__name__ ) raise Exception(msg) return django.forms.models.modelform_factory( form_model, fields=fields, form=otree.forms.ModelForm ) def before_next_page(self): pass def get_form(self, data=None, files=None, **kwargs): """Given `data` and `files` QueryDicts, and optionally other named arguments, and returns a form. """ cls = self.get_form_class() return cls(data=data, files=files, view=self, **kwargs) def form_invalid(self, form): context = self.get_context_data(form=form) fields_with_errors = [fname for fname in form.errors if fname != '__all__'] # i think this should be before we call render_to_response # because the view (self) is passed to the template and rendered if fields_with_errors: self.first_field_with_errors = fields_with_errors[0] self.other_fields_with_errors = fields_with_errors[1:] response = self.render_to_response(context) response[ otree.constants.redisplay_with_errors_http_header ] = otree.constants.get_param_truth_value return response def post(self): request = self.request self.object = self.get_object() if self.participant.is_browser_bot: submission = browser_bots.pop_enqueued_post_data( participant_code=self.participant.code ) # convert MultiValueKeyDict to regular dict # so that we can add entries to it in a simple way # before, we used dict(request.POST), but that caused # errors with BooleanFields with blank=True that were # submitted empty...it said [''] is not a valid value post_data = request.POST.dict() post_data.update(submission) else: post_data = request.POST form = self.get_form(data=post_data, files=request.FILES, instance=self.object) self.form = form auto_submitted = post_data.get(otree.constants.timeout_happened) # if the page doesn't have a timeout_seconds, only the timeoutworker # should be able to auto-submit it. # otherwise users could append timeout_happened to the URL to skip pages has_secret_code = ( post_data.get(otree.constants.admin_secret_code) == ADMIN_SECRET_CODE ) # todo: make sure users can't change the result by removing 'timeout_happened' # from URL if auto_submitted and (has_secret_code or self.has_timeout_()): self.timeout_happened = True # for public API self._process_auto_submitted_form(form) else: self.timeout_happened = False is_bot = self.participant._is_bot if form.is_valid(): if is_bot and post_data.get('must_fail'): msg = ( 'Page "{}": Bot tried to submit intentionally invalid ' 'data with ' 'SubmissionMustFail, but it passed validation anyway:' ' {}.'.format( self.__class__.__name__, bot_prettify_post_data(post_data) ) ) raise BotError(msg) # assigning to self.object is not really necessary self.object = form.save() else: if is_bot: PageName = self.__class__.__name__ if not post_data.get('must_fail'): errors = [ "{}: {}".format(k, repr(v)) for k, v in form.errors.items() ] msg = ( 'Page "{}": Bot submission failed form validation: {} ' 'Check your bot code, ' 'then create a new session. ' 'Data submitted was: {}'.format( PageName, errors, bot_prettify_post_data(post_data) ) ) raise BotError(msg) if post_data.get('error_fields'): # need to convert to dict because MultiValueKeyDict # doesn't properly retrieve values that are lists post_data_dict = dict(post_data) expected_error_fields = set(post_data_dict['error_fields']) actual_error_fields = set(form.errors.keys()) if not expected_error_fields == actual_error_fields: msg = ( 'Page {}, SubmissionMustFail: ' 'Expected error_fields were {}, but actual ' 'error_fields are {}'.format( PageName, expected_error_fields, actual_error_fields ) ) raise BotError(msg) response = self.form_invalid(form) self.browser_bot_stuff(response) return response try: self.before_next_page() except Exception as exc: # why not raise ResponseForException? return response_for_exception(self.request, exc) self._record_page_completion_time() self._increment_index_in_pages() return self._redirect_to_page_the_user_should_be_on() def get_object(self): Cls = self._get_form_model() if Cls == self.GroupClass: return self.group if Cls == self.PlayerClass: return self.player if Cls == UndefinedFormModel: return UndefinedFormModel.objects.all()[0] def socket_url(self): '''called from template. can't start with underscore because used in template ''' return channel_utils.auto_advance_path( participant_code=self.participant.code, page_index=self._index_in_pages ) def redirect_url(self): '''called from template''' # need full path because we use query string return self.request.get_full_path() def _get_timeout_submission(self): timeout_submission = self.timeout_submission or {} for field_name in self.get_form_fields(): if field_name not in timeout_submission: # get default value for datatype if the user didn't specify ModelClass = self._get_form_model() ModelField = ModelClass._meta.get_field(field_name) # TODO: should we warn if the attribute doesn't exist? value = getattr(ModelField, 'auto_submit_default', None) timeout_submission[field_name] = value return timeout_submission def _process_auto_submitted_form(self, form): ''' # an empty submitted form looks like this: # {'f_currency': None, 'f_bool': None, 'f_int': None, 'f_char': ''} ''' timeout_submission = self._get_timeout_submission() # force the form to be cleaned form.is_valid() has_non_field_error = form.errors.pop('__all__', False) # In a non-timeout form, error_message is only run if there are no # field errors (because the error_message function assumes all fields exist) # however, if there is a timeout, we accept the form even if there are some field errors, # so we have to make sure we don't skip calling error_message() if form.errors and not has_non_field_error: if hasattr(self, 'error_message'): try: has_non_field_error = bool(self.error_message(form.cleaned_data)) except: has_non_field_error = True if has_non_field_error: # non-field errors exist. # ignore form, use timeout_submission entirely auto_submit_values_to_use = timeout_submission elif form.errors: auto_submit_values_to_use = {} for field_name in form.errors: auto_submit_values_to_use[field_name] = timeout_submission[field_name] form.errors.clear() form.save() else: auto_submit_values_to_use = {} form.save() for field_name in auto_submit_values_to_use: setattr(self.object, field_name, auto_submit_values_to_use[field_name]) def has_timeout_(self): participant = self.participant return ( participant._timeout_page_index == participant._index_in_pages and participant._timeout_expiration_time is not None ) # don't use lru_cache. it is a global cache # @cached_property only in python 3.8 _remaining_timeout_seconds = 'unset' def remaining_timeout_seconds(self): if self._remaining_timeout_seconds == 'unset': self._remaining_timeout_seconds = self.remaining_timeout_seconds_inner() return self._remaining_timeout_seconds def remaining_timeout_seconds_inner(self): current_time = time.time() participant = self.participant if participant._timeout_page_index == participant._index_in_pages: if participant._timeout_expiration_time is None: return None return participant._timeout_expiration_time - current_time try: timeout_seconds = self.get_timeout_seconds() except: raise ResponseForException participant._timeout_page_index = participant._index_in_pages if timeout_seconds is None: participant._timeout_expiration_time = None return None participant._timeout_expiration_time = current_time + timeout_seconds if otree.common.USE_TIMEOUT_WORKER: # if using browser bots, don't schedule the timeout, # because if it's a short timeout, it could happen before # the browser bot submits the page. Because the timeout # doesn't query the botworker (it is distinguished from bot # submits by the timeout_happened flag), it will "skip ahead" # and therefore confuse the bot system. if not self.participant.is_browser_bot: otree.tasks.submit_expired_url( participant_code=self.participant.code, path=self.request.path, # add some seconds to account for latency of request + response # this will (almost) ensure # (1) that the page will be submitted by JS before the # timeoutworker, which ensures that self.request.POST # actually contains a value. # (2) that the timeoutworker doesn't accumulate a lead # ahead of the real page, which could result in being >1 # page ahead. that means that entire pages could be skipped delay=timeout_seconds + 6, ) return timeout_seconds def get_timeout_seconds(self): return self.timeout_seconds timeout_seconds = None timeout_submission = None timer_text = ugettext_lazy("Time left to complete this page:") class GenericWaitPageMixin: """used for in-game wait pages, as well as other wait-type pages oTree has (like waiting for session to be created, or waiting for players to be assigned to matches """ request = None def redirect_url(self): '''called from template''' # need get_full_path because we use query string here return self.request.get_full_path() def get_template_names(self): '''built-in wait pages should not be overridable''' return ['otree/WaitPage.html'] def _get_wait_page(self): self.participant.is_on_wait_page = True self._update_monitor_table() response = TemplateResponse( self.request, self.get_template_names(), self.get_context_data() ) response[ otree.constants.wait_page_http_header ] = otree.constants.get_param_truth_value return response # Translators: the default title of a wait page title_text = ugettext_lazy('Please wait') body_text = None def _get_default_body_text(self): ''' needs to be a method because it could say "waiting for the other player", "waiting for the other players"... ''' return '' def get_context_data(self): title_text = self.title_text body_text = self.body_text # could evaluate to false like 0 if body_text is None: body_text = self._get_default_body_text() # default title/body text can be overridden # if user specifies it in vars_for_template return {'view': self, 'title_text': title_text, 'body_text': body_text} class WaitPage(FormPageOrInGameWaitPage, GenericWaitPageMixin): """ Wait pages during game play (i.e. checkpoints), where users wait for others to complete """ wait_for_all_groups = False group_by_arrival_time = False def get_context_data(self): context = GenericWaitPageMixin.get_context_data(self) return FormPageOrInGameWaitPage.get_context_data(self, **context) def get_template_names(self): """fallback to otree/WaitPage.html, which is guaranteed to exist. the reason for the 'if' statement, rather than returning a list, is that if the user explicitly defined template_name, and that template does not exist, then we should not fail silently. (for example, the user forgot to add it to git) """ if self.template_name: return [self.template_name] return ['global/WaitPage.html', 'otree/WaitPage.html'] def inner_dispatch(self, *args, **kwargs): # necessary because queries are made directly from DB if self.wait_for_all_groups == True: resp = self.inner_dispatch_subsession() elif self.group_by_arrival_time: resp = self.inner_dispatch_gbat() else: resp = self.inner_dispatch_group() return resp def _run_aapa_and_notify(self, group_or_subsession): '''new design is that if anybody is waiting on the wait page, we run AAPA. If nobody is shown the wait page, we don't need to notify or even create a CompletedGroupWaitPage record. ''' if self.wait_for_all_groups: group = None else: group = group_or_subsession if isinstance(self.after_all_players_arrive, str): aapa_method = getattr(group_or_subsession, self.after_all_players_arrive) else: wp: WaitPage = type(self)() wp.set_attributes_waitpage_clone(original_view=self) wp._group_for_wp_clone = group aapa_method = wp.after_all_players_arrive try: aapa_method() except: raise ResponseForException self._mark_completed_and_notify(group=group) def inner_dispatch_group(self): ## EARLY EXITS if CompletedGroupWaitPage.objects.filter( page_index=self._index_in_pages, group_id=self.player.group_id, session_id=self._session_pk, ).exists(): return self._response_when_ready() is_displayed = self._is_displayed() is_last, someone_waiting = self._tally_unvisited() if is_displayed and not is_last: return self._get_wait_page() elif is_last and (someone_waiting or is_displayed): self._run_aapa_and_notify(self.group) return self._response_when_ready() def inner_dispatch_subsession(self): if CompletedSubsessionWaitPage.objects.filter( page_index=self._index_in_pages, session=self.session ).exists(): return self._response_when_ready() is_displayed = self._is_displayed() is_last, someone_waiting = self._tally_unvisited() if is_displayed and not is_last: return self._get_wait_page() elif is_last and (someone_waiting or is_displayed): self._run_aapa_and_notify(self.subsession) return self._response_when_ready() def inner_dispatch_gbat(self): if CompletedGBATWaitPage.objects.filter( page_index=self._index_in_pages, id_in_subsession=self.group.id_in_subsession, session=self.session, ).exists(): return self._response_when_ready() if not self._is_displayed(): # in GBAT, either all players should skip a page, or none should. # we don't support some players skipping and others not. return self._response_when_ready() participant = self.participant participant._gbat_is_waiting = True participant._gbat_page_index = self._index_in_pages participant._gbat_grouped = False # _last_request_timestamp is already set in set_attributes, # but set it here just so we can guarantee participant._last_request_timestamp = time.time() # need to save it inside the lock (check-then-act) # also because it needs to be up to date for get_players_for_group # which gets this info from the DB participant.save() # make a clean copy for GBAT and AAPA # self.player and self.participant etc are undefined # and no objects are cached inside it # and it doesn't affect the current instance gbat_new_group = self.subsession._gbat_try_to_make_new_group( self._index_in_pages ) if gbat_new_group: self._run_aapa_and_notify(gbat_new_group) # gbat_new_group may not include the current player! # maybe this will not work if i change the implementation # so that the player is cached, # but that's OK because it will be obvious it doesn't work. if participant._gbat_grouped: return self._response_when_ready() return self._get_wait_page() @property def _group_or_subsession(self): return self.subsession if self.wait_for_all_groups else self.group # this is needed because on wait pages, self.player doesn't exist. # usually oTree finds the group by doing self.player.group. _group_for_wp_clone = None @property def group(self): return self._group_for_wp_clone or super().group def _mark_page_completions(self, player_values): ''' this is more accurate than page load, because the player may delay doing that, to make it look like they waited longer. ''' app_name = self.player._meta.app_config.name session_code = self.participant._session_code for p in player_values: otree.common2.make_page_completion_row( view=self, app_name=app_name, participant__id_in_session=p['participant__id_in_session'], participant__code=p['participant__code'], session_code=session_code, is_wait_page=1, ) def _mark_completed_and_notify(self, group: Optional[BaseGroup]): # if group is not passed, then it's the whole subsession # could be 2 people creating the record at the same time # in _increment_index_in_pages, so could end up creating 2 records # but it's not a problem. base_kwargs = dict(page_index=self._index_in_pages, session_id=self._session_pk) if self.wait_for_all_groups: CompletedSubsessionWaitPage.objects.create(**base_kwargs) obj = self.subsession elif self.group_by_arrival_time: CompletedGBATWaitPage.objects.create( **base_kwargs, id_in_subsession=group.id_in_subsession ) obj = group else: CompletedGroupWaitPage.objects.create(**base_kwargs, group_id=group.id) obj = group player_values = obj.player_set.values( 'participant__id_in_session', 'participant__code', 'participant__pk' ) self._mark_page_completions(player_values) Participant.objects.filter( id__in=[p['participant__pk'] for p in player_values] ).update(_last_page_timestamp=time.time()) # this can cause messages to get wrongly enqueued in the botworker if otree.common.USE_TIMEOUT_WORKER and not self.participant.is_browser_bot: participant_pks = [p['participant__pk'] for p in player_values] # 2016-11-15: we used to only ensure the next page is visited # if the next page has a timeout, or if it's a wait page # but this is not reliable because next page might be skipped anyway, # and we don't know what page will actually be shown next to the user. otree.tasks.ensure_pages_visited( participant_pks=participant_pks, delay=10, ) if self.group_by_arrival_time: channel_utils.sync_group_send_wrapper( type='gbat_ready', group=channel_utils.gbat_group_name(**base_kwargs), event={}, ) else: if self.wait_for_all_groups: channels_group_name = channel_utils.subsession_wait_page_name( **base_kwargs ) else: channels_group_name = channel_utils.group_wait_page_name( **base_kwargs, group_id=group.id ) channel_utils.sync_group_send_wrapper( type='wait_page_ready', group=channels_group_name, event={} ) def socket_url(self): session_pk = self._session_pk page_index = self._index_in_pages participant_id = self.participant.pk if self.group_by_arrival_time: return channel_utils.gbat_path( session_pk=session_pk, page_index=page_index, app_name=self.player._meta.app_config.name, participant_id=participant_id, player_id=self.player.id, ) elif self.wait_for_all_groups: return channel_utils.subsession_wait_page_path( session_pk=session_pk, page_index=page_index, participant_id=participant_id, ) else: return channel_utils.group_wait_page_path( session_pk=session_pk, page_index=page_index, participant_id=participant_id, group_id=self.player.group_id, ) def _tally_unvisited(self): participant_ids = list( self._group_or_subsession.player_set.values_list( 'participant__id', flat=True ) ) participants = Participant.objects.filter(id__in=participant_ids) session_code = self.participant._session_code visited = [] unvisited = [] for p in participants: [unvisited, visited][p._index_in_pages >= self._index_in_pages].append(p) # this is not essential to functionality. # just for the display in the Monitor tab. if len(unvisited) <= 3: if len(unvisited) == 0: note = '' else: note = ', '.join(p._numeric_label() for p in unvisited) for p in visited: p._monitor_note = note p.save() channel_utils.sync_group_send_wrapper( type='update_notes', group=channel_utils.session_monitor_group_name(session_code), event=dict(ids=[p.id_in_session for p in visited], note=note), ) is_last = not bool(unvisited) someone_waiting = any( [ p._index_in_pages == self._index_in_pages and p.is_on_wait_page for p in participants ] ) return (is_last, someone_waiting) def is_displayed(self): return True def _response_when_ready(self): ''' Before calling this function, the following must be satisfied: - The completion object exists OR - The player skips this page ''' participant = self.participant participant.is_on_wait_page = False participant._monitor_note = None self._increment_index_in_pages() return self._redirect_to_page_the_user_should_be_on() def after_all_players_arrive(self): pass def _get_default_body_text(self): num_other_players = self._group_or_subsession.player_set.count() - 1 if num_other_players > 1: return _('Waiting for the other participants.') if num_other_players == 1: return _('Waiting for the other participant.') return '' class AdminSessionPageMixin: @classmethod def url_pattern(cls): return r"^{}/(?P[a-z0-9]+)/$".format(cls.__name__) def get_context_data(self, **kwargs): context = super().get_context_data( session=self.session, is_debug=settings.DEBUG, request=self.request, **kwargs, ) # vars_for_template has highest priority context.update(self.vars_for_template()) return context def vars_for_template(self): ''' simpler to use vars_for_template, but need to use get_context_data when: - you need access to the context produced by the parent class, such as the form ''' return {} def get_template_names(self): return ['otree/admin/{}.html'.format(self.__class__.__name__)] def dispatch(self, request, code, **kwargs): self.session = get_object_or_404(otree.models.Session, code=code) return super().dispatch(request, **kwargs) class InvalidAppError(Exception): pass REST_KEY_NAME = 'OTREE_REST_KEY' REST_KEY_HEADER = 'otree-rest-key' @method_decorator(csrf_exempt, name='dispatch') class BaseRESTView(vanilla.View): def dispatch(self, request, *args, **kwargs): # hack to force plain text 500 page (Django checks .is_ajax()) request.META['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest' if settings.AUTH_LEVEL in ['DEMO', 'STUDY']: REST_KEY = os.getenv(REST_KEY_NAME) # put it here for easy testing if not REST_KEY: return HttpResponseForbidden( f'Env var {REST_KEY_NAME} must be defined to use REST API' ) submitted_rest_key = request.headers.get(REST_KEY_HEADER) if not submitted_rest_key: return HttpResponseForbidden( f'HTTP Request Header {REST_KEY_HEADER} is missing' ) if REST_KEY != submitted_rest_key: return HttpResponseForbidden( f'HTTP Request Header {REST_KEY_HEADER} is incorrect' ) self.payload = json.loads(request.body.decode("utf-8")) return super().dispatch(request, *args, **kwargs) def post(self, request): return self.inner_post(**self.payload) def get(self, request): return self.inner_get(**self.payload)