\x20\t\r\n\f]*)[^>]*)\/>/gi,Aa=/
+
+
+Server Error (500)
+
+
+ There are several ways to find the cause of the issue:
+
+
+
+ - Set the
OTREE_PRODUCTION
environment variable back to 0
and reload this page
+ - Look at your Sentry messages (see the docs on how to enable Sentry)
+ - Look at the server logs
+
+
+
+
\ No newline at end of file
diff --git a/otree/templates/django/forms/widgets/attrs.html b/otree/templates/django/forms/widgets/attrs.html
new file mode 100644
index 0000000..ff8f4b4
--- /dev/null
+++ b/otree/templates/django/forms/widgets/attrs.html
@@ -0,0 +1,12 @@
+{% load l10n %}{% localize off %}
+ {# need to use |default so it fails silently #}
+ {% with widget.type|default:"" as wtype %}
+ {% for name, value in widget.attrs.items %}
+ {{ name }}{% if wtype != "checkbox" and wtype != "radio" and name == "class" %}="form-control
+ {{ value }}"
+ {% else %}{% if value is not True %}="{{ value }}"{% endif %}
+ {% endif %}{% endfor %}
+ {% if wtype != "checkbox" and wtype != "radio" and "class" not in widget.attrs %} class="form-control"{% endif %}
+ {% endwith %}
+
+{% endlocalize %}
diff --git a/otree/templates/django/forms/widgets/multiple_input.html b/otree/templates/django/forms/widgets/multiple_input.html
new file mode 100644
index 0000000..182d6bc
--- /dev/null
+++ b/otree/templates/django/forms/widgets/multiple_input.html
@@ -0,0 +1,6 @@
+{# override of django's built-in multiple_input, except add |default to nonexistent widget.attrs.class #}
+{% with id=widget.attrs.id %}{% endwith %}
diff --git a/otree/templates/global/Base.html b/otree/templates/global/Base.html
new file mode 100644
index 0000000..8dd0617
--- /dev/null
+++ b/otree/templates/global/Base.html
@@ -0,0 +1 @@
+{% extends "otree/Page.html" %}
diff --git a/otree/templates/global/Page.html b/otree/templates/global/Page.html
new file mode 100644
index 0000000..1e3afe4
--- /dev/null
+++ b/otree/templates/global/Page.html
@@ -0,0 +1,10 @@
+{% extends "global/Base.html" %}
+
+{% comment %}
+This is a fallback in case the project was created before we renamed Base.html
+to Page.html. This will ensure that customizations to Base.html will still
+be applied, even if the template inherits from Page.html.
+
+If the user has a Page.html, however, it will take precedence over this
+template.
+{% endcomment %}
diff --git a/otree/templates/otree/Base.html b/otree/templates/otree/Base.html
new file mode 100644
index 0000000..2933f90
--- /dev/null
+++ b/otree/templates/otree/Base.html
@@ -0,0 +1,41 @@
+{% load i18n otree static %}
+{# NOTE: keep this compact so that view-source is friendly for users #}
+
+
+
+ {% block head_title %}{% block title %}
+ {% endblock %}{% endblock %}
+
+ {% block internal_styles %}
+
+
+
+
+ {% comment %}
+ this actually belongs in internal_scripts, but we are in the process
+ of deprecating it, so some people's apps might still rely on jQuery being
+ available within the content block.
+ {% endcomment %}
+
+ {% endblock %}
+ {# these blocks are for public API #}
+ {% block global_styles %}{% endblock %}
+ {% block app_styles %}{% endblock %}
+ {% block styles %}{% endblock %}
+
+
+{% block body_main %}{% endblock %}
+
+{% block internal_scripts %}
+
+ {% block bootstrap_scripts %}
+
+ {% endblock %}
+
+{% endblock %}
+{# these blocks are for public API #}
+{% block global_scripts %}{% endblock %}
+{% block app_scripts %}{% endblock %}
+{% block scripts %}{% endblock %}
+
+
diff --git a/otree/templates/otree/BaseAdmin.html b/otree/templates/otree/BaseAdmin.html
new file mode 100644
index 0000000..81b23b9
--- /dev/null
+++ b/otree/templates/otree/BaseAdmin.html
@@ -0,0 +1,91 @@
+{% extends "otree/Base.html" %}
+{% load i18n %}
+{% load otree otree_internal static %}
+
+{% block internal_styles %}
+ {{ block.super }}
+
+
+
+
+{% endblock %}
+
+{% block internal_scripts %}
+{{ block.super }}
+
+
+
+
+
+
+
+{% endblock %}
+{% block body_main %}
+
+
+
+
+ {% block menus %}{% endblock %}
+
+ {% block content %}{% endblock %}
+
+
+
+
+{% endblock %}
+
diff --git a/otree/templates/otree/DemoIndex.html b/otree/templates/otree/DemoIndex.html
new file mode 100644
index 0000000..8336f11
--- /dev/null
+++ b/otree/templates/otree/DemoIndex.html
@@ -0,0 +1,41 @@
+{% extends "otree/BaseAdmin.html" %}
+
+{% block title %}
+ {{ title }}
+{% endblock %}
+
+{% block content %}
+
+
+
+
+ {{ intro_html|safe }}
+
+ {% if is_debug %}
+
+ You can add entries to this list in
+ settings.py
.
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/otree/templates/otree/FormPage.html b/otree/templates/otree/FormPage.html
new file mode 100644
index 0000000..64e1177
--- /dev/null
+++ b/otree/templates/otree/FormPage.html
@@ -0,0 +1 @@
+{% extends "otree/Page.html" %}
\ No newline at end of file
diff --git a/otree/templates/otree/MTurkPreview.html b/otree/templates/otree/MTurkPreview.html
new file mode 100644
index 0000000..b5fca1a
--- /dev/null
+++ b/otree/templates/otree/MTurkPreview.html
@@ -0,0 +1,3 @@
+{% extends 'otree/Base.html' %}
+
+{% block body_main %}{% block content %}{% endblock %}{% endblock %}
\ No newline at end of file
diff --git a/otree/templates/otree/OutOfRangeNotification.html b/otree/templates/otree/OutOfRangeNotification.html
new file mode 100644
index 0000000..13b7b55
--- /dev/null
+++ b/otree/templates/otree/OutOfRangeNotification.html
@@ -0,0 +1,13 @@
+{% extends "otree/Base.html" %}
+{% load i18n %}
+
+{% block body_main %}
+
+
{% block title %}Finished{% endblock %}
+
+
+ {% blocktrans trimmed %}No more pages left to show.{% endblocktrans %}
+
+
+
+{% endblock %}
diff --git a/otree/templates/otree/Page.html b/otree/templates/otree/Page.html
new file mode 100644
index 0000000..90157be
--- /dev/null
+++ b/otree/templates/otree/Page.html
@@ -0,0 +1,53 @@
+{% extends "otree/Base.html" %}
+{% load i18n %}
+{% load otree static %}
+{% comment %}
+NOTE:
+we should keep this page as simple as possible so that 'view source' is friendly
+i removed many linebreaks to make output HTML cleaner
+{% endcomment %}
+{% block body_main %}
+
+
+ {% if view.remaining_timeout_seconds != None %}
+ {% include 'otree/includes/TimeLimit.html' with form_element_id="form" %}
+ {% endif %}
+ {% if form.errors %}
+
+ {% blocktrans trimmed %}Please fix the errors in the form.{% endblocktrans %}
+ {{ form.non_field_errors }}
+
+ {% endif %}
+
+
+ {% if view.is_debug|default:False %}
+
+ {% include 'otree/includes/debug_info.html' %}
+ {% endif %}
+
+{% endblock %}
+{% block internal_styles %}
+{{ block.super }}
+
+{% endblock %}
+{% block internal_scripts %}
+ {{ block.super }}
+
+ {% comment %}
+ the below is a special flag.
+ 2018-04-25: why not put it in a response header?
+ ...this had to do with handling an edge case with OutOfRangeNotification.
+ look at this again when i have time.
+ {% endcomment %}
+ {% if participant.is_browser_bot %} {% endif %}
+ {% if view.remaining_timeout_seconds != None %}
+ {% include 'otree/includes/TimeLimit.js.html' %}
+ {% endif %}
+{% endblock %}
diff --git a/otree/templates/otree/RoomInputLabel.html b/otree/templates/otree/RoomInputLabel.html
new file mode 100644
index 0000000..7612850
--- /dev/null
+++ b/otree/templates/otree/RoomInputLabel.html
@@ -0,0 +1,24 @@
+{% extends "otree/Base.html" %}
+{% load i18n otree %}
+
+{% block body_main %}
+
+
+ {% block title %}{% trans 'Welcome' %}{% endblock %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/otree/templates/otree/WaitPage.html b/otree/templates/otree/WaitPage.html
new file mode 100644
index 0000000..28bff55
--- /dev/null
+++ b/otree/templates/otree/WaitPage.html
@@ -0,0 +1,153 @@
+{% extends 'otree/Base.html' %}
+{% load i18n %}
+{% load staticfiles %}
+{% block head_title %}{{ title_text }}{% endblock %}
+
+{% block internal_styles %}
+ {{ block.super }}
+
+{% endblock %}
+
+{% block body_main %}
+
+
+
+
+
+
+ {% blocktrans trimmed %}An error occurred. Please check the logs or ask the administrator for help.{% endblocktrans %}
+
+
+ {% block content %}
+
{{ body_text }}
+ {% endblock %}
+
+
+
+ {% if view.is_debug|default:False %}
+ {% include 'otree/includes/debug_info.html' %}
+ {% endif %}
+
+{% endblock %}
+
+{% block internal_scripts %}
+ {{ block.super }}
+
+
+ {% endblock %}
+
diff --git a/otree/templates/otree/WaitPageRoom.html b/otree/templates/otree/WaitPageRoom.html
new file mode 100644
index 0000000..ffae7a8
--- /dev/null
+++ b/otree/templates/otree/WaitPageRoom.html
@@ -0,0 +1,23 @@
+{% extends 'otree/WaitPage.html' %}
+{% load i18n %}
+{% load staticfiles %}
+{% block internal_scripts %}
+ {{ block.super }}
+
+
+
+
+ {% endblock %}
diff --git a/otree/templates/otree/admin/AdminReport.html b/otree/templates/otree/admin/AdminReport.html
new file mode 100644
index 0000000..d083370
--- /dev/null
+++ b/otree/templates/otree/admin/AdminReport.html
@@ -0,0 +1,36 @@
+{% extends "otree/admin/Session.html" %}
+{% load otree i18n %}
+
+
+{% block content %}
+ {{ block.super }}
+
+
+ {% include user_template %}
+
+ {% if view.is_debug|default:False %}
+
+ {% include 'otree/includes/debug_info.html' %}
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/otree/templates/otree/admin/CreateSession.html b/otree/templates/otree/admin/CreateSession.html
new file mode 100644
index 0000000..555dcb1
--- /dev/null
+++ b/otree/templates/otree/admin/CreateSession.html
@@ -0,0 +1,11 @@
+{% extends "otree/BaseAdmin.html" %}
+
+{% block title %}
+ Create a new session
+{% endblock %}
+
+{% block content %}
+
+{% include "otree/includes/CreateSessionForm.html" %}
+
+{% endblock %}
diff --git a/otree/templates/otree/admin/Export.html b/otree/templates/otree/admin/Export.html
new file mode 100644
index 0000000..bec7edd
--- /dev/null
+++ b/otree/templates/otree/admin/Export.html
@@ -0,0 +1,219 @@
+{% extends "otree/BaseAdmin.html" %}
+{% load staticfiles otree %}
+
+{% block title %}
+ Data Export
+{% endblock %}
+
+
+{% block content %}
+
+ {% if db_is_empty %}
+ No sessions have taken place yet.
+ {% else %}
+
+
+
+
+
+ If you publish research conducted using oTree,
+ you are required by the oTree license to cite this
+ paper.
+
+
+ Citation:
+
+
+ Chen, D.L., Schonger, M., Wickens, C., 2016. oTree - An
+ open-source
+ platform for laboratory, online and field experiments.
+ Journal of Behavioral and Experimental Finance, vol 9:
+ 88-97
+
+
+
+
+
+ All apps
+
+
+ Excel
+ |
+ CSV
+
+
+ Data for all apps in one file.
+ There is one row per participant;
+ different apps and rounds are stacked horizontally.
+ This format is useful if you want to correlate participants'
+ behavior in one app with their behavior in another app.
+
+
+ Per-app
+
+ These files contain a row for each player in the given app.
+ If there are multiple rounds, there will be multiple rows for the
+ same participant.
+ This format is useful if you are mainly interested in one app,
+ or if you want to correlate data between rounds of the same app.
+
+
+
+
+
+ App |
+ Data |
+ Documentation |
+
+
+
+ {% for app in app_names %}
+
+
+ {{ app }}
+ |
+
+ Excel
+ |
+ CSV
+ |
+
+ TXT
+ |
+
+ |
+
+ {% endfor %}
+
+
+ Time spent on each page
+
+
+ Download
+
+ {% if chat_messages_exist %}
+ Chat logs
+
+
+ Download
+ {% endif %}
+
+ {% endif %}
+
+
+
+ {% if extensions_views %}
+ Third-party data exports
+
+ {% for ViewCls in extensions_views %}
+
+
+ {{ ViewCls.display_name }}
+ {% endfor %}
+
+ {% endif %}
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+
+
+{% endblock %}
diff --git a/otree/templates/otree/admin/MTurkCreateHIT.html b/otree/templates/otree/admin/MTurkCreateHIT.html
new file mode 100644
index 0000000..6d59645
--- /dev/null
+++ b/otree/templates/otree/admin/MTurkCreateHIT.html
@@ -0,0 +1,131 @@
+{% extends "otree/admin/Session.html" %}
+{% load otree i18n %}
+
+{% block content %}
+
+{{ block.super }}
+
+{% if not mturk_ready %}
+ MTurk is currently disabled.
+ If you want to publish your HIT on MTurk please do the following
+ steps:
+
+
+
+
+ Step |
+ Done? |
+
+
+
+ Run pip3 install otree[mturk]
+ and in your requirements_base.txt ,
+ replace otree==N.N.N with otree[mturk]==N.N.N .
+ This will install oTree along with extra MTurk-specific packages.
+ |
+ {% if boto3_installed %}Yes{% else %}No{% endif %} |
+
+
+
+ Set the settings AWS_ACCESS_KEY_ID and
+ AWS_SECRET_ACCESS_KEY |
+ {% if aws_keys_exist %}Yes{% else %}No{% endif %} |
+
+
+
+ View this page with HTTPS.
+
+ -
+ If using Heroku, you can simply change the URL
+ in your browser's address bar to start with 'https://'
+ and reload this page.
+
+
+
+ |
+ {% if https %}Yes{% else %}No{% endif %} |
+
+
+
+
+ You can read more about Amazon Mechanical Turk integration
+ here.
+
+{% else %}
+ {% if form.errors %}
+
+ {% blocktrans trimmed %}Please fix the errors in the form.{% endblocktrans %}
+
+ {% endif %}
+
+
+{% endif %}
+
+{% include "otree/includes/messages.html" %}
+{% endblock %}
diff --git a/otree/templates/otree/admin/MTurkSessionPayments.html b/otree/templates/otree/admin/MTurkSessionPayments.html
new file mode 100644
index 0000000..adc4220
--- /dev/null
+++ b/otree/templates/otree/admin/MTurkSessionPayments.html
@@ -0,0 +1,288 @@
+{% extends "otree/admin/Session.html" %}
+{% load otree %}
+
+{% block internal_scripts %}
+ {{ block.super }}
+
+{% endblock %}
+{% block content %}
+ {{ block.super }}
+
+ {% if not published %}
+
+ The MTurk payments page will appear after you publish this
+ session
+ to MTurk.
+
+ {% else %}
+
+
Session
+
+
+ Session type |
+ {{ session.config.name }} |
+
+
+
+ Session code |
+ {{ session.code }} |
+
+
+
+ MTurk Hit Id |
+ {{ session.mturk_HITId }} |
+
+
+
+ Experimenter name |
+ {{ session.experimenter_name|default_if_none:"" }} |
+
+
+
+
+ {% if participants_not_reviewed %}
+
+
+
+ {% endif %}
+
+
+ {% if participants_approved %}
+
Approved assignments
+
+
+ Participant code |
+ Assignment Id |
+ Worker Id |
+
+ Participation fee (Reward)
+ |
+
+ Variable pay (Bonus)
+ |
+ Total pay |
+
+
+ {% for p in participants_approved %}
+
+ {{ p.code }} |
+ {{ p.mturk_assignment_id|default_if_none:"" }} |
+ {{ p.mturk_worker_id|default_if_none:"" }} |
+
+ {{ participation_fee }}
+ |
+
+ {{ p.payoff_in_real_world_currency }}
+ |
+ {{ p.payoff_plus_participation_fee }} |
+
+ {% endfor %}
+
+ {% endif %}
+ {% if participants_rejected %}
+
Rejected assignments
+
+
+ Participant code |
+ Assignment Id |
+ Worker Id |
+
+ Participation fee (Reward)
+ |
+
+ Variable pay (Bonus)
+ |
+ Total pay |
+
+
+ {% for p in participants_rejected %}
+
+ {{ p.code }} |
+ {{ p.mturk_assignment_id|default_if_none:"" }} |
+ {{ p.mturk_worker_id|default_if_none:"" }} |
+
+ {{ participation_fee }}
+ |
+
+ {{ p.payoff_in_real_world_currency }}
+ |
+ {{ p.payoff_plus_participation_fee }} |
+
+ {% endfor %}
+
+ {% endif %}
+ {% if not participants_not_reviewed and not participants_approved and not participants_rejected %}
+
You have no participants who finished the
+ experiment.
+ {% endif %}
+
+{% endif %}
+ {% include "otree/includes/messages.html" %}
+{% endblock %}
diff --git a/otree/templates/otree/admin/RoomWithSession.html b/otree/templates/otree/admin/RoomWithSession.html
new file mode 100644
index 0000000..c0b4aa7
--- /dev/null
+++ b/otree/templates/otree/admin/RoomWithSession.html
@@ -0,0 +1,24 @@
+{% extends "otree/BaseAdmin.html" %}
+
+{% block title %}
+ Room: {{ room.display_name }}
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ {% include "otree/includes/RoomParticipantLinks.html" %}
+
+{% endblock %}
diff --git a/otree/templates/otree/admin/RoomWithoutSession.html b/otree/templates/otree/admin/RoomWithoutSession.html
new file mode 100644
index 0000000..2b4ed35
--- /dev/null
+++ b/otree/templates/otree/admin/RoomWithoutSession.html
@@ -0,0 +1,200 @@
+{% extends "otree/BaseAdmin.html" %}
+{% load otree %}
+
+{% block title %}
+ Room: {{ room.display_name }}
+{% endblock %}
+
+{% block content %}
+
+ Create a new session
+
+ {% include "otree/includes/CreateSessionForm.html" %}
+
+
+ participants present
+
+ {% if room.has_participant_labels %}
+
+
+
+ {% for participant_label in room.get_participant_labels %}
+
+ {{ participant_label }}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% if room.has_participant_labels %}
+ {# if there's no participant labels, there's no concept of participants absent #}
+ participants not present
+
+
+
+ {% for participant_label in room.get_participant_labels %}
+ {{ participant_label }}
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+
+ {% include "otree/includes/RoomParticipantLinks.html" %}
+
+{% endblock %}
+
+{% block internal_styles %}
+ {{ block.super }}
+
+
+
+{% endblock %}
+
+
+{% block internal_scripts %}
+ {{ block.super }}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/otree/templates/otree/admin/Rooms.html b/otree/templates/otree/admin/Rooms.html
new file mode 100644
index 0000000..7e11871
--- /dev/null
+++ b/otree/templates/otree/admin/Rooms.html
@@ -0,0 +1,19 @@
+{% extends "otree/BaseAdmin.html" %}
+
+{% block title %}
+ Rooms
+{% endblock %}
+
+{% block content %}
+
+Current rooms:
+
+{% for room in rooms %}
+
+{% endfor %}
+
+
+
+{% endblock %}
diff --git a/otree/templates/otree/admin/ServerCheck.html b/otree/templates/otree/admin/ServerCheck.html
new file mode 100644
index 0000000..1cd2ffc
--- /dev/null
+++ b/otree/templates/otree/admin/ServerCheck.html
@@ -0,0 +1,146 @@
+{% extends "otree/BaseAdmin.html" %}
+
+{% block title %}
+ Server Readiness Checks
+{% endblock %}
+
+{% block content %}
+
+
+ For details on how to fix any issues highlighted below,
+ see here.
+
+
+ {% if pypi_results.pypi_connection_error %}
+
+
Update status unknown
+ Could not connect to
PyPI
+ to check if your otree package is up to date.
+
+ {% elif pypi_results.update_needed %}
+
+ You are using an old oTree version
+ {{ pypi_results.update_message }}
+
+ {% else %}
+
+ You have a recent version of oTree ({{ pypi_results.installed_version }}).
+
+ {% endif %}
+
+ {% if sqlite %}
+
+ Using SQLite You are using SQLite, which is only suitable during development and testing
+ of your app. Before launching a study, you should upgrade to Postgres (or MySQL etc).
+
+ {% else %}
+
+ You are using a proper database (Postgres, MySQL, etc).
+
+ {% endif %}
+
+ {% if debug %}
+
+
DEBUG mode is on
+ You should only use DEBUG mode during development and testing
+ of your app.
+ Before launching a study, you should switch DEBUG mode off.
+ To turn off DEBUG mode,
+ {% if heroku %}
+ run:
+
heroku config:set OTREE_PRODUCTION=1
+ {% else %}
+ set the environment variable
OTREE_PRODUCTION
to
1
.
+ {% endif %}
+
+ {% else %}
+
+ DEBUG mode is off
+
+ {% endif %}
+
+ {% if runserver %}
+
+ You are using otree runserver
,
+ which is only suitable for local development.
+ When launching a real study, you should run a proper multi-process server,
+ e.g. otree runprodserver
.
+
+ {% else %}
+
+ You are using a server other than runserver
.
+
+ {% endif %}
+
+
+ {% if not sentry %}
+
+ Sentry not configured
+ Sentry can send you the details of each server error by email.
+ This is necessary because once you have turned off DEBUG
mode,
+ you will no longer see Django’s yellow error pages;
+ you or your users will just see generic "500 server error" pages.
+ oTree offers a free Sentry service;
+ you can find the sign-up link in the oTree documentation.
+
+ {% else %}
+
+ Sentry is configured.
+
+ {% endif %}
+
+ {% if not auth_level_ok %}
+
+ No password protection
+ To prevent unauthorized server access, you should
+ set the environment variable OTREE_AUTH_LEVEL
.
+
+ {% else %}
+
+ Password protection is on.
+ Your app's AUTH_LEVEL
is {{ auth_level }}
.
+
+ {% endif %}
+
+ {% if not db_synced %}
+
+ Database is missing tables
+ You should run otree resetdb
.
+
+ {% else %}
+
+ Your database appears to be synced.
+
+ {% endif %}
+
+ {% if not runserver %}
+ {% if worker_is_running %}
+
+ The worker process is running
+
+ {% else %}
+
+
No worker process found
+
The worker process enables the following functionality:
+
+ - Browser bots
+ - If your study has timeouts on pages (with
timeout_seconds
),
+ then the timeoutworker will automatically advance a user
+ when they exceed the timeout,
+ even if they close their browser.
+
+
+
+ {% if heroku %}
+
In your app dashboard, make sure the second dyno is turned on.
+ {% else %}
+
+ It is launched automatically as part of otree runprodserver
.
+
+ {% endif %}
+
+ {% endif %}
+ {% endif %}
+
+{% endblock %}
+
diff --git a/otree/templates/otree/admin/Session.html b/otree/templates/otree/admin/Session.html
new file mode 100644
index 0000000..1d12655
--- /dev/null
+++ b/otree/templates/otree/admin/Session.html
@@ -0,0 +1,59 @@
+{% extends "otree/BaseAdmin.html" %}
+{% load otree_internal staticfiles %}
+
+{% block head_title %}
+{{ session.config.display_name }}: session '{{ session.code }}'{% if session.is_demo %} (demo) {% endif %}
+{% endblock %}
+
+{% block title %}
+{{ session.config.display_name }}: session {{ session.code }}
{% if session.is_demo %} (demo) {% endif %}
+{% endblock %}
+
+{% block menus %}
+
+{% endblock %}
diff --git a/otree/templates/otree/admin/SessionData.html b/otree/templates/otree/admin/SessionData.html
new file mode 100644
index 0000000..e8ece93
--- /dev/null
+++ b/otree/templates/otree/admin/SessionData.html
@@ -0,0 +1,57 @@
+{% extends "otree/admin/Session.html" %}
+
+{% block internal_scripts %}
+{{ block.super }}
+
+{% endblock %}
+
+{% block content %}
+{{ block.super }}
+
+
+
+ ID in session |
+ {% for header, colspan in subsession_headers %}
+ {{ header }} |
+ {% endfor %}
+
+
+ {% for header, colspan in model_headers %}
+ {{ header }} |
+ {% endfor %}
+
+
+ {% for header in field_headers %}
+ {{ header }} |
+ {% endfor %}
+
+
+
+
+
×
+ "Failed to connect to server"
+
+You can download data in Excel or CSV format here.
+{% endblock %}
diff --git a/otree/templates/otree/admin/SessionDescription.html b/otree/templates/otree/admin/SessionDescription.html
new file mode 100644
index 0000000..c06621f
--- /dev/null
+++ b/otree/templates/otree/admin/SessionDescription.html
@@ -0,0 +1,6 @@
+{% extends "otree/admin/Session.html" %}
+
+{% block content %}
+{{ block.super }}
+{% include 'otree/includes/SessionInfo.html' %}
+{% endblock %}
diff --git a/otree/templates/otree/admin/SessionEditProperties.html b/otree/templates/otree/admin/SessionEditProperties.html
new file mode 100644
index 0000000..fdb6028
--- /dev/null
+++ b/otree/templates/otree/admin/SessionEditProperties.html
@@ -0,0 +1,13 @@
+{% extends "otree/admin/Session.html" %}
+{% load otree %}
+
+{% block content %}
+ {{ block.super }}
+ {% include "otree/includes/messages.html" %}
+
+
+{% endblock %}
diff --git a/otree/templates/otree/admin/SessionMonitor.html b/otree/templates/otree/admin/SessionMonitor.html
new file mode 100644
index 0000000..867cfd5
--- /dev/null
+++ b/otree/templates/otree/admin/SessionMonitor.html
@@ -0,0 +1,172 @@
+{% extends "otree/admin/Session.html" %}
+
+{% block content %}
+ {{ block.super }}
+
+
+
+ {% for header in column_names %}
+ {{ header }} |
+ {% endfor %}
+
+
+
+
+ /{{ session.num_participants }} participants started.
+
+ {% if not session.use_browser_bots %}
+
+ {% endif %}
+
+
+
+
+ Failed to refresh data from the server.
+
+
+
+ An error occurred and participants could not be advanced.
+ See the server logs for details.
+
+
+ {% csrf_token %}
+
+{% endblock %}
+
+
+{% block internal_scripts %}
+{{ block.super }}
+
+{% endblock %}
diff --git a/otree/templates/otree/admin/SessionPayments.html b/otree/templates/otree/admin/SessionPayments.html
new file mode 100644
index 0000000..b9ab8da
--- /dev/null
+++ b/otree/templates/otree/admin/SessionPayments.html
@@ -0,0 +1,77 @@
+{% extends "otree/admin/Session.html" %}
+{% block content %}
+{{ block.super }}
+
+
+ Generated: {% now "DATETIME_FORMAT" %}
+
+
+
+
Session
+
+
+ Session config |
+ {{ session.config.name }} |
+
+
+
+ Session code |
+ {{ session.code }} |
+
+
+
+ Experimenter name |
+ {{ session.experimenter_name|default_if_none:"" }} |
+
+
+
+
+
Participants
+
+
+
+
+ Participant code |
+ Participant label |
+ Progress |
+ Participation fee |
+ Payoff (bonus) |
+ Total |
+ Note |
+
+
+ {% for p in participants %}
+
+ {{ p.code }} |
+ {{ p.label|default_if_none:"" }} |
+ {{ p.current_page_ }} |
+ {{ participation_fee }} |
+ {{ p.payoff_in_real_world_currency }} |
+ {{ p.payoff_plus_participation_fee }} |
+ |
+
+ {% endfor %}
+
+
+
+
Summary
+
+
+ Total payments |
+ {{ total_payments }} |
+
+
+ Mean payment |
+ {{ mean_payment }} |
+
+
+
+
+
Notes/Signature
+
+
+
+
+
+
+{% endblock %}
diff --git a/otree/templates/otree/admin/SessionSplitScreen.html b/otree/templates/otree/admin/SessionSplitScreen.html
new file mode 100644
index 0000000..70e783f
--- /dev/null
+++ b/otree/templates/otree/admin/SessionSplitScreen.html
@@ -0,0 +1,99 @@
+{% load staticfiles %}
+{% load i18n %}
+
+
+
+ Split Screen Demo
+
+
+
+
+
+
+ ···
+
+ {% for participant_url in participant_urls %}
+
+ {% endfor %}
+
+
+
+
+
diff --git a/otree/templates/otree/admin/SessionStartLinks.html b/otree/templates/otree/admin/SessionStartLinks.html
new file mode 100644
index 0000000..5f0cfc8
--- /dev/null
+++ b/otree/templates/otree/admin/SessionStartLinks.html
@@ -0,0 +1,145 @@
+{% extends "otree/admin/Session.html" %}
+
+
+{% block content %}
+{{ block.super }}
+
+ {% if use_browser_bots %}
+
+
+ Links will play automatically with browser bots.
+ To disable, go to settings.py and set 'use_browser_bots': False
+ in the session config.
+
+
+ {% endif %}
+
+{% if room|default:False %}
+
+
+
+ This session is taking place in the room
+ {# apply underline because alert-info makes blue link on blue background invisible #}
+ {{ room.display_name }}.
+
+
+
+ {% include "otree/includes/RoomParticipantLinks.html" %}
+
+ {% comment %}
+ need this because otherwise, if a participant closes their browser,
+ there is no way for them to resume playing.
+ {% endcomment %}
+ {% if not room.has_participant_labels %}
+ Single-use links
+
+
+ Below are single-use links for this session only.
+ You can use these in the event that you need to open (or re-open) a specific
+ participant's link.
+
+
+
+
+
+
+ {% for participant_url in session_start_urls %}
+
+ P{{ forloop.counter }} |
+ {{ participant_url }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+{% elif session.is_demo %}
+
+ Below are temporary links for testing and demonstration.
+ To launch a real study, either create persistent links by setting up a room,
+ or create a session through the sessions page.
+
+
+
+ You can either open
+ {% if splitscreen_mode_on %} split-screen mode, {% endif %}
+ the session-wide link, or the single-use links.
+
+
+ {% if splitscreen_mode_on %}
+ Split screen mode
+
+ Play in split screen mode.
+
+ {% endif %}
+
+ Session-wide link
+
+ Open the below link in up to {{ num_participants }} browser tabs.
+
+
+ {{ anonymous_url }}
+
+ Single-use links
+
+ Open each link in its own browser tab.
+
+
+ {% for participant_url in participant_urls %}
+
+ P{{ forloop.counter }} |
+ {{ participant_url }} |
+
+ {% endfor %}
+
+
+{% else %}
+
+
+ You can either use the session-wide link, persistent links,
+ or single-use links.
+
+
+ Session-wide link
+
+
+ If it is impractical to distribute distinct URLs to each participant,
+ you can give the following start URL to all {{ num_participants }} participants.
+
+
+ {{ anonymous_url }}
+
+
+ Note: unlike the other link modes, this does not prevent the same person from
+ playing twice.
+
+
+ Persistent links
+
+
+ If you want to give your participants permanent links that don't change,
+ you should create your session in a room.
+
+
+ Single-use links
+
+
+ Below are single-use links, which you can distribute to your participants.
+ Each link has a unique code for the participant.
+
+
+
+ {% for participant_url in participant_urls %}
+
+ P{{ forloop.counter }} |
+ {{ participant_url }} |
+
+ {% endfor %}
+
+
+
+{% endif %}
+{% endblock %}
diff --git a/otree/templates/otree/admin/Sessions.html b/otree/templates/otree/admin/Sessions.html
new file mode 100644
index 0000000..d202a3c
--- /dev/null
+++ b/otree/templates/otree/admin/Sessions.html
@@ -0,0 +1,199 @@
+{% extends "otree/BaseAdmin.html" %}
+{% load staticfiles %}
+
+{% block internal_scripts %}
+{{ block.super }}
+
+
+{% endblock %}
+
+{% block title %}
+ {% if is_archive %}
+ Archived Sessions
+ {% else %}
+ Sessions
+ {% endif %}
+{% endblock %}
+
+{% block content %}
+{{ block.super }}
+
+
+
+
+
+ {% if object_list %}
+
+ {% endif %}
+ {% if not is_archive and archived_sessions_exist %}
+
+ {% endif %}
+ {% include "otree/includes/messages.html" %}
+{% endblock %}
diff --git a/otree/templates/otree/forms/errors.html b/otree/templates/otree/forms/errors.html
new file mode 100644
index 0000000..76b77d6
--- /dev/null
+++ b/otree/templates/otree/forms/errors.html
@@ -0,0 +1 @@
+{% if errors %}{% for error in errors %}{{ error }}{% if not forloop.last %}
{% endif %}{% endfor %}
{% endif %}
diff --git a/otree/templates/otree/forms/layouts/bootstrap.html b/otree/templates/otree/forms/layouts/bootstrap.html
new file mode 100644
index 0000000..efa25ec
--- /dev/null
+++ b/otree/templates/otree/forms/layouts/bootstrap.html
@@ -0,0 +1,13 @@
+{% load otree %}
+{% for error in form.non_field_errors %}
+
+{% endfor %}
+
+{% for field in form.visible_fields %}
+ {% formfield field %}
+{% endfor %}
+
+{% if not form.visible_fields %}{% for field in form.hidden_fields %}{{ field }}{% endfor %}{% endif %}
diff --git a/otree/templates/otree/forms/moneyinput.html b/otree/templates/otree/forms/moneyinput.html
new file mode 100644
index 0000000..2aeeced
--- /dev/null
+++ b/otree/templates/otree/forms/moneyinput.html
@@ -0,0 +1,6 @@
+
diff --git a/otree/templates/otree/forms/radio_select_horizontal.html b/otree/templates/otree/forms/radio_select_horizontal.html
new file mode 100644
index 0000000..eabe806
--- /dev/null
+++ b/otree/templates/otree/forms/radio_select_horizontal.html
@@ -0,0 +1,10 @@
+{% with id=widget.attrs.id %}
+ {% for group, options, index in widget.optgroups %}
+ {% for option in options %}
+
+ {% endfor %}
+ {% endfor %}
+{% endwith %}
diff --git a/otree/templates/otree/forms/rows/bootstrap.html b/otree/templates/otree/forms/rows/bootstrap.html
new file mode 100644
index 0000000..d19e869
--- /dev/null
+++ b/otree/templates/otree/forms/rows/bootstrap.html
@@ -0,0 +1,19 @@
+{% load otree otree_internal %}
+
diff --git a/otree/templates/otree/forms/rows/bootstrap_checkbox.html b/otree/templates/otree/forms/rows/bootstrap_checkbox.html
new file mode 100644
index 0000000..6741bcc
--- /dev/null
+++ b/otree/templates/otree/forms/rows/bootstrap_checkbox.html
@@ -0,0 +1,14 @@
+{% load otree otree_internal %}
+
diff --git a/otree/templates/otree/forms/slider.html b/otree/templates/otree/forms/slider.html
new file mode 100644
index 0000000..946a773
--- /dev/null
+++ b/otree/templates/otree/forms/slider.html
@@ -0,0 +1,10 @@
+{% if show_value %}
+
+{% else %}
+ {% include "django/forms/widgets/input.html" %}
+{% endif %}
diff --git a/otree/templates/otree/includes/CreateSessionForm.html b/otree/templates/otree/includes/CreateSessionForm.html
new file mode 100644
index 0000000..358c4f8
--- /dev/null
+++ b/otree/templates/otree/includes/CreateSessionForm.html
@@ -0,0 +1,100 @@
+{% load otree staticfiles %}
+
+
+
+
diff --git a/otree/templates/otree/includes/OtreeDotOrgFeedbackWidget.html b/otree/templates/otree/includes/OtreeDotOrgFeedbackWidget.html
new file mode 100644
index 0000000..2dbeff6
--- /dev/null
+++ b/otree/templates/otree/includes/OtreeDotOrgFeedbackWidget.html
@@ -0,0 +1,43 @@
+
\ No newline at end of file
diff --git a/otree/templates/otree/includes/RoomParticipantLinks.html b/otree/templates/otree/includes/RoomParticipantLinks.html
new file mode 100644
index 0000000..c956013
--- /dev/null
+++ b/otree/templates/otree/includes/RoomParticipantLinks.html
@@ -0,0 +1,65 @@
+Persistent URLs
+
+
+ These URLs will stay constant for new sessions,
+ even if the database is recreated.
+
+
+
+{% if room.has_participant_labels %}
+Participant-specific URLs
+
+
+ These URLs contain the labels,
+ so participants don't have to enter their label manually.
+
+
+ {% if collapse_links %}
+
+
+ {% endif %}
+
+
+
+ {% comment %}
+ this is a table to be consistent with other start links listings,
+ which may include participant label column
+ {% endcomment %}
+
+ {% for participant_url in participant_urls %}
+
+ {# eventually add participant labels #}
+ {{ participant_url }} |
+
+ {% endfor %}
+
+
+
+{% endif %}
+
+
+{% if room.use_secure_urls %}
+ {# then dont use room-wide URL #}
+{% else %}
+
+ Room-wide URL
+
+ Here is the room-wide URL anyone can use.
+ {% if room.has_participant_labels %}
+ Users will be prompted to enter their participant label,
+ which will be validated against your participant_label_file.
+ {% else %}
+ Don't use this link when testing in multiple tabs on the same browser,
+ because all tabs will be assigned to the same participant,
+ using a cookie.
+ {% endif %}
+
+
+ {{ room_wide_url }}
+{% endif %}
+
+
+
+
diff --git a/otree/templates/otree/includes/SessionInfo.html b/otree/templates/otree/includes/SessionInfo.html
new file mode 100644
index 0000000..34fc728
--- /dev/null
+++ b/otree/templates/otree/includes/SessionInfo.html
@@ -0,0 +1,25 @@
+
+
+{% if config.doc %}
+
Session description
+
+ {{ config.doc|safe }}
+
+{% endif %}
+
+
App sequence
+
+
+{% for app in config.app_sequence_display %}
+
+ {{ app.name }} |
+
+
+ {{ app.doc|safe }}
+
+ |
+
+{% endfor %}
+
+
+
\ No newline at end of file
diff --git a/otree/templates/otree/includes/TimeLimit.html b/otree/templates/otree/includes/TimeLimit.html
new file mode 100644
index 0000000..8366a0a
--- /dev/null
+++ b/otree/templates/otree/includes/TimeLimit.html
@@ -0,0 +1,10 @@
+{% load static %}
+{% load i18n %}
+
+
+
{{ timer_text }}
+
+
+
+
+
diff --git a/otree/templates/otree/includes/TimeLimit.js.html b/otree/templates/otree/includes/TimeLimit.js.html
new file mode 100644
index 0000000..255ec55
--- /dev/null
+++ b/otree/templates/otree/includes/TimeLimit.js.html
@@ -0,0 +1,26 @@
+{% load staticfiles otree %}
+
+
+
+
diff --git a/otree/templates/otree/includes/debug_info.html b/otree/templates/otree/includes/debug_info.html
new file mode 100644
index 0000000..1477625
--- /dev/null
+++ b/otree/templates/otree/includes/debug_info.html
@@ -0,0 +1,31 @@
+{% load staticfiles otree %}
+{# spaces to make debug info less distracting #}
+
+
+
+
+
+ {# .debug-info so it's visible in inspector #}
+
+
+ {% include 'otree/includes/hidden_form_errors.html' %}
+
+{% for table in view.debug_tables|default:None %}
+
+{% endfor %}
+
+
diff --git a/otree/templates/otree/includes/hidden_form_errors.html b/otree/templates/otree/includes/hidden_form_errors.html
new file mode 100644
index 0000000..2fe5f8f
--- /dev/null
+++ b/otree/templates/otree/includes/hidden_form_errors.html
@@ -0,0 +1,60 @@
+{% if view.first_field_with_errors|default:False %}
+
+
+
+ The form field {{ view.first_field_with_errors }}
has errors,
+ but its error message is not being displayed, possibly because
+ you did not include the field in the page.
+ There are 2 ways to fix this:
+
+ -
+ Include the field with the
formfield
tag, e.g.
+ {% templatetag openblock %}
+ formfield player.{{ view.first_field_with_errors }}
+ {% templatetag closeblock %}
+
+ -
+ If you are not using
formfield
but are instead
+ writing the raw HTML for the form input,
+ remember to include
+ {% templatetag openvariable %}
+ form.{{ view.first_field_with_errors }}.errors
+ {% templatetag closevariable %}
+ somewhere in your page's HTML.
+
+
+
+ {% if view.other_fields_with_errors %}
+
The following other field(s) have the same issue:
+
+ {% for field_with_error in view.other_fields_with_errors %}
+ -
+
{{ field_with_error }}
+
+ {% endfor %}
+
+
+ While debugging, you can display all errors in the form with
+ {% templatetag openvariable %}
+ form.errors
+ {% templatetag closevariable %}
.
+
+ {% endif %}
+
+
+ {% comment %}
+ formfield: error
+ form.errors or form.foo.errors:
+ {% endcomment %}
+
+
+
+{% endif %}
\ No newline at end of file
diff --git a/otree/templates/otree/includes/messages.html b/otree/templates/otree/includes/messages.html
new file mode 100644
index 0000000..4be19ab
--- /dev/null
+++ b/otree/templates/otree/includes/messages.html
@@ -0,0 +1,6 @@
+{% for message in messages %}
+
+{% endfor %}
diff --git a/otree/templates/otree/login.html b/otree/templates/otree/login.html
new file mode 100644
index 0000000..eb66feb
--- /dev/null
+++ b/otree/templates/otree/login.html
@@ -0,0 +1,60 @@
+{% extends "otree/BaseAdmin.html" %}
+{% load i18n otree otree_internal %}
+{% block title %}Admin Login{% endblock %}
+
+{% block content %}
+ {% if form.errors and not form.non_field_errors %}
+
+ Please correct the error(s).
+
+ {% endif %}
+
+ {% if form.non_field_errors %}
+ {% for error in form.non_field_errors %}
+
+ {{ error }}
+
+ {% endfor %}
+ {% endif %}
+
+ {% if form.errors %}
+
+ If you already set a password but want to change it,
+ first you may need to reset the database.
+
+ {% endif %}
+
+ {% ensure_superuser_exists %}
+
+
+{% endblock %}
diff --git a/otree/templates/otree/tags/NextButton.html b/otree/templates/otree/tags/NextButton.html
new file mode 100644
index 0000000..ec7a314
--- /dev/null
+++ b/otree/templates/otree/tags/NextButton.html
@@ -0,0 +1,6 @@
+{% load i18n %}
+
+
+ {% comment %}Translators: The text on the button the user clicks to get to the next page{% endcomment %}
+
+
diff --git a/otree/templates/otree/tags/_formfield.html b/otree/templates/otree/tags/_formfield.html
new file mode 100644
index 0000000..2a26b2d
--- /dev/null
+++ b/otree/templates/otree/tags/_formfield.html
@@ -0,0 +1,5 @@
+{% if bound_field.field.widget.input_type|default:None == "checkbox" %}
+ {% include "otree/forms/rows/bootstrap_checkbox.html" with field=bound_field %}
+{% else %}
+ {% include "otree/forms/rows/bootstrap.html" with field=bound_field %}
+{% endif %}
diff --git a/otree/templates/otreechat_core/widget.html b/otree/templates/otreechat_core/widget.html
new file mode 100644
index 0000000..7fd2131
--- /dev/null
+++ b/otree/templates/otreechat_core/widget.html
@@ -0,0 +1,133 @@
+{% load otree %}
+
+
+
+
+
+
+
+
diff --git a/otree/templatetags/__init__.py b/otree/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/otree/templatetags/otree.py b/otree/templatetags/otree.py
new file mode 100644
index 0000000..b46ffb2
--- /dev/null
+++ b/otree/templatetags/otree.py
@@ -0,0 +1,52 @@
+from otree.currency import Currency
+
+from .otree_tags import (
+ template, FormFieldNode,
+)
+from otree.chat import chat_template_tag
+from otree.api import safe_json
+
+
+# renaming otree_tags to otree and removing internal tags
+# this code is duplicated in otree_tags. I duplicate it rather than importing
+# register, because PyCharm's autocomplete doesn't detect the import and
+# flags all the template tags in yellow.
+
+register = template.Library()
+register.tag('formfield', FormFieldNode.parse)
+
+NEXT_BUTTON_TEMPLATE_PATH = 'otree/tags/NextButton.html'
+@register.inclusion_tag(NEXT_BUTTON_TEMPLATE_PATH)
+def next_button(*args, **kwargs):
+ return {}
+
+
+def my_abs(val):
+ '''
+ it seems you can't use a builtin as a filter_func:
+ File "C:\oTree\venv\lib\site-packages\django\template\base.py", line 1179, in filter
+ filter_func._filter_name = name
+ AttributeError: 'builtin_function_or_method' object has no attribute '_filter_name'
+ '''
+ return abs(val)
+
+# it seems that if you use positional args, PyCharm autocomplete doesn't work
+# (highlights in yellow)
+register.filter('abs', my_abs)
+
+# use decorator because that way, PyCharm
+# will autocomplete it correctly (no yellow highlight)
+# i think that's safer than registering the Currency function directly,
+# because it seems that Library.filter mutates the filter_func (see above)
+@register.filter
+def c(val):
+ return Currency(val)
+
+@register.filter
+def json(val):
+ return safe_json(val)
+
+# this code is duplicated in otree_tags.py
+@register.inclusion_tag('otreechat_core/widget.html', takes_context=True, name='chat')
+def chat(context, *args, **kwargs):
+ return chat_template_tag(context, *args, **kwargs)
diff --git a/otree/templatetags/otree_forms.py b/otree/templatetags/otree_forms.py
new file mode 100644
index 0000000..6d280f9
--- /dev/null
+++ b/otree/templatetags/otree_forms.py
@@ -0,0 +1,165 @@
+from collections import namedtuple
+import sys
+from typing import List, Dict
+from django.db import models
+from django.template import Node
+from django.template import TemplateSyntaxError
+from django.template import Variable, Context
+from django.template import VariableDoesNotExist
+from django.template.base import token_kwargs
+from django.template.loader import render_to_string
+
+from django.utils import six
+
+from otree.models_concrete import UndefinedFormModel
+from django.template.base import Token, FilterExpression
+
+
+class FormFieldNode(Node):
+ default_template = 'otree/tags/_formfield.html'
+
+ def __init__(self, field_variable_name, label_arg:FilterExpression):
+ self.field_variable_name = field_variable_name
+ self.label_arg = label_arg
+
+ def get_form_instance(self, context):
+ try:
+ return Variable('form').resolve(context)
+ except VariableDoesNotExist as exception:
+ msg = (
+ "The 'formfield' templatetag expects a 'form' variable "
+ "in the context.")
+ ExceptionClass = type(exception)
+ six.reraise(
+ ExceptionClass,
+ ExceptionClass(msg + ' ' + str(exception)),
+ sys.exc_info()[2])
+
+ def resolve_bound_field(self, context):
+ bound_field = Variable(self.field_variable_name).resolve(context)
+ return bound_field
+
+ def get_bound_field(self, context):
+ # First we try to resolve the {% formfield player.name %} syntax were
+ # player is a model instance.
+ if '.' in self.field_variable_name:
+ form = self.get_form_instance(context)
+ instance_name_in_template, field_name = \
+ self.field_variable_name.split('.', -1)
+ instance_in_template = Variable(
+ instance_name_in_template).resolve(context)
+ # it could be form.my_field
+ if isinstance(instance_in_template, models.Model):
+ instance_from_view = form.instance
+ if type(instance_from_view) == UndefinedFormModel:
+ raise ValueError(
+ 'Template contains a formfield, but '
+ 'you did not set form_model on the Page class.'
+ )
+ elif type(instance_in_template) != type(instance_from_view):
+ raise ValueError(
+ 'In the page class, you set form_model to {!r}, '
+ 'but in the template you have a formfield for '
+ '"{}", which is a different model.'.format(
+ type(instance_from_view),
+ instance_name_in_template,
+ )
+ )
+ # we should ensure that Player and Group have readable
+ # __repr__
+ elif instance_in_template != instance_from_view:
+ raise ValueError(
+ "You have a formfield for '{}' "
+ "({!r}), which is different from "
+ "the expected model instance "
+ "({!r}).".format(
+ instance_name_in_template,
+ instance_in_template,
+ form.instance))
+ try:
+ return form[field_name]
+ except KeyError:
+ raise ValueError(
+ "'{field_name}' was used as a formfield in the template, "
+ "but was not included in the Page's 'form_fields'".format(
+ field_name=field_name)) from None
+
+ # Second we try to resolve it to a bound field.
+ # No field found, so we return None.
+ bound_field = Variable(self.field_variable_name).resolve(context)
+
+ # We assume it's a BoundField when 'as_widget', 'as_hidden' and
+ # 'errors' attribtues are available.
+ if (
+ not hasattr(bound_field, 'as_widget') or
+ not hasattr(bound_field, 'as_hidden') or
+ not hasattr(bound_field, 'errors')):
+ raise ValueError(
+ "The given variable '{variable_name}' ({variable!r}) is "
+ "neither a model field nor a form field.".format(
+ variable_name=self.field_variable_name,
+ variable=bound_field))
+ return bound_field
+
+ def get_tag_specific_context(self, context: Context) -> Dict:
+ bound_field = self.get_bound_field(context)
+ extra_context = {
+ 'bound_field': bound_field,
+ 'help_text': bound_field.help_text
+ }
+ if self.label_arg:
+ label = self.label_arg.resolve(context)
+ # If the with argument label="" was set explicitly, we set it to
+ # None. That is required to differentiate between 'use the default
+ # label since we didn't set any in the template' and 'do not print
+ # a label at all' as defined in
+ # https://github.com/oTree-org/otree-core/issues/325
+ else:
+ label = bound_field.label
+ extra_context['label'] = label
+ return extra_context
+
+ def render(self, context: Context):
+ t = context.template.engine.get_template(self.default_template)
+ tag_specific_context = self.get_tag_specific_context(context)
+ new_context = context.new(tag_specific_context)
+ return t.render(new_context)
+
+ @classmethod
+ def parse(cls, parser, token: Token):
+
+ # here is how split_contents() works:
+
+ # {% formfield player.f1 label="f1 label" %}
+ # ...yields:
+ # ['formfield', 'player.f1', 'label="f1 label"']
+
+ # {% formfield player.f2 "f2 label with no kwarg" %}
+ # ...yields:
+ # ['formfield', 'player.f2', '"f2 label with no kwarg"']
+
+ # handle where the user did {% formfield player.f label = "foo" %}
+ token.contents = token.contents.replace('label = ', 'label=')
+ bits = token.split_contents()
+ tagname = bits.pop(0)
+ if len(bits) < 1:
+ raise TemplateSyntaxError(
+ "{tagname!r} requires at least one argument.".format(
+ tagname=tagname))
+ field = bits.pop(0)
+ if bits:
+ if bits[0] == 'with':
+ bits.pop(0)
+ arg_dict = token_kwargs(bits, parser, support_legacy=False)
+ label_arg = arg_dict.pop('label', None)
+ for key in arg_dict:
+ msg = '{} tag received unknown argument "{}"'.format(tagname, key)
+ raise TemplateSyntaxError(msg)
+ else:
+ label_arg = None
+ if bits:
+ raise TemplateSyntaxError(
+ 'Unknown argument for {tagname} tag: {bits!r}'.format(
+ tagname=tagname,
+ bits=bits))
+ return cls(field, label_arg=label_arg)
diff --git a/otree/templatetags/otree_internal.py b/otree/templatetags/otree_internal.py
new file mode 100644
index 0000000..016f97b
--- /dev/null
+++ b/otree/templatetags/otree_internal.py
@@ -0,0 +1,48 @@
+from django import template
+from django.urls import reverse, Resolver404
+import otree.common_internal
+
+
+NO_USER_MSG = '''
+You must set ADMIN_USERNAME and
+ADMIN_PASSWORD in settings.py
+(or disable authentication by unsetting AUTH_LEVEL).
+'''
+
+
+register = template.Library()
+
+
+@register.filter
+def id(bound_field):
+ widget = bound_field.field.widget
+ for_id = widget.attrs.get('id') or bound_field.auto_id
+ if for_id:
+ for_id = widget.id_for_label(for_id)
+ return for_id
+
+
+def active_page(request, view_name, *args, **kwargs):
+ if not request:
+ return ""
+ try:
+ url = reverse(view_name, args=args)
+ return "active" if url == request.path_info else ""
+ except Resolver404:
+ return ""
+
+
+def ensure_superuser_exists():
+ '''
+ Creates a superuser on the fly, so that the user doesn't have to migrate
+ or resetdb to get a superuser.
+ If eventually we use migrations instead of resetdb, then maybe won't
+ need this anymore.
+ '''
+ return otree.common_internal.ensure_superuser_exists()
+
+
+register.simple_tag(name='ensure_superuser_exists',
+ func=ensure_superuser_exists)
+register.simple_tag(name='active_page', func=active_page)
+
diff --git a/otree/templatetags/otree_tags.py b/otree/templatetags/otree_tags.py
new file mode 100644
index 0000000..49cf107
--- /dev/null
+++ b/otree/templatetags/otree_tags.py
@@ -0,0 +1,37 @@
+from django import template
+
+from otree.chat import chat_template_tag
+from otree.common import safe_json
+from otree.currency import Currency
+from otree.templatetags.otree_internal import active_page, \
+ ensure_superuser_exists
+from .otree_forms import FormFieldNode
+
+
+def c(val):
+ return Currency(val)
+
+def my_abs(val):
+ '''
+ it seems you can't use a builtin as a filter_func:
+ File "C:\oTree\venv\lib\site-packages\django\template\base.py", line 1179, in filter
+ filter_func._filter_name = name
+ AttributeError: 'builtin_function_or_method' object has no attribute '_filter_name'
+ '''
+ return abs(val)
+
+register = template.Library()
+register.tag('formfield', FormFieldNode.parse)
+register.filter(name='c', filter_func=c)
+register.filter(name='abs', filter_func=my_abs)
+register.filter('json', safe_json)
+
+# this code is duplicated in otree.py
+@register.inclusion_tag('otreechat_core/widget.html', takes_context=True)
+def chat(context, *args, **kwargs):
+ return chat_template_tag(context, *args, **kwargs)
+
+@register.inclusion_tag('otree/tags/NextButton.html')
+def next_button(*args, **kwargs):
+ return {}
+
diff --git a/otree/test/__init__.py b/otree/test/__init__.py
new file mode 100644
index 0000000..fe38a8f
--- /dev/null
+++ b/otree/test/__init__.py
@@ -0,0 +1,2 @@
+# for compat with apps written in older versions
+from otree.bots import Bot # noqa
diff --git a/otree/timeout/__init__.py b/otree/timeout/__init__.py
new file mode 100644
index 0000000..faa18be
--- /dev/null
+++ b/otree/timeout/__init__.py
@@ -0,0 +1,2 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
diff --git a/otree/timeout/tasks.py b/otree/timeout/tasks.py
new file mode 100644
index 0000000..20c7616
--- /dev/null
+++ b/otree/timeout/tasks.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+import django.test
+from huey.contrib.djhuey import db_task
+
+from otree import constants_internal
+
+
+test_client = django.test.Client()
+
+
+@db_task()
+def submit_expired_url(participant_code, url):
+ from otree.models.participant import Participant
+
+ # if the participant exists in the DB,
+ # and they did not advance past the page yet
+
+ # To reduce redundant server traffic, it's OK not to advance the page if the user already got to the next page
+ # themselves, or via "advance slowest participants".
+ # however, we must make sure that the user succeeded in loading the next page fully.
+ # if the user made this page's POST but closed their browser before
+ # the redirect to the next page's GET, then if the next page has a timeout,
+ # it will not get scheduled, and then the auto-timeout chain would be broken.
+ # so, instead of filtering by _index_in_pages (which is set in POST),
+ # we filter by _current_form_page_url (which is set in GET,
+ # AFTER the next page's timeout is scheduled.)
+
+ if Participant.objects.filter(
+ code=participant_code,
+ _current_form_page_url=url).exists():
+ test_client.post(
+ url, data={constants_internal.timeout_happened: True}, follow=True)
+
+
+@db_task()
+def ensure_pages_visited(participant_pk_set):
+ """This is necessary when a wait page is followed by a timeout page.
+ We can't guarantee the user's browser will properly continue to poll
+ the wait page and get redirected, so after a grace period we load the page
+ automatically, to kick off the expiration timer of the timeout page.
+ """
+
+ from otree.models.participant import Participant
+
+ # we used to filter by _index_in_pages, but that is not reliable,
+ # because of the race condition described above.
+ unvisited_participants = Participant.objects.filter(
+ pk__in=participant_pk_set,
+ )
+ for participant in unvisited_participants:
+
+ # if the wait page is the first page,
+ # then _current_form_page_url could be null.
+ # in this case, use the start_url() instead,
+ # because that will redirect to the current wait page.
+ # (alternatively we could define _current_page_url or
+ # current_wait_page_url)
+ url = participant._url_i_should_be_on()
+ test_client.get(url, follow=True)
diff --git a/otree/urls.py b/otree/urls.py
new file mode 100644
index 0000000..3a5962c
--- /dev/null
+++ b/otree/urls.py
@@ -0,0 +1,191 @@
+from otree.extensions import get_extensions_modules, get_extensions_data_export_views
+import inspect
+from importlib import import_module
+from django.templatetags.static import static
+from django.conf import urls
+
+from django.contrib.staticfiles.urls import staticfiles_urlpatterns
+from django.views.generic.base import RedirectView
+from django.conf import settings
+
+from django.contrib.auth.decorators import login_required
+from otree import common_internal
+
+
+STUDY_UNRESTRICTED_VIEWS = {
+ 'AssignVisitorToRoom',
+ 'InitializeParticipant',
+ 'MTurkLandingPage',
+ 'MTurkStart',
+ 'JoinSessionAnonymously',
+ 'OutOfRangeNotification',
+ 'ParticipantRoomHeartbeat',
+}
+
+
+DEMO_UNRESTRICTED_VIEWS = STUDY_UNRESTRICTED_VIEWS.union({
+ 'AdminReport',
+ 'AdvanceSession',
+ 'CreateDemoSession',
+ 'DemoIndex',
+ 'SessionSplitScreen',
+ 'SessionDescription',
+ 'SessionMonitor',
+ 'SessionPayments',
+ 'SessionData',
+ 'SessionStartLinks',
+ 'WaitUntilSessionCreated',
+})
+
+
+def view_classes_from_module(module_name):
+ views_module = import_module(module_name)
+
+ # what about custom views?
+ return [
+ ViewCls for _, ViewCls in inspect.getmembers(views_module)
+ if hasattr(ViewCls, 'url_pattern') and
+ inspect.getmodule(ViewCls) == views_module
+ ]
+
+
+def url_patterns_from_game_module(module_name, name_in_url):
+ views_module = import_module(module_name)
+
+ all_views = [
+ ViewCls
+ for _, ViewCls in inspect.getmembers(views_module)
+ if hasattr(ViewCls, 'url_pattern')]
+
+ view_urls = []
+ for ViewCls in all_views:
+
+ url_pattern = ViewCls.url_pattern(name_in_url)
+ url_name = ViewCls.url_name()
+ view_urls.append(
+ urls.url(url_pattern, ViewCls.as_view(), name=url_name)
+ )
+
+ return view_urls
+
+
+def url_patterns_from_module(module_name):
+ """automatically generates URLs for all Views in the module,
+ So that you don't need to enumerate them all in urlpatterns.
+ URLs take the form "gamename/ViewName".
+ See the method url_pattern() for more info
+
+ So call this function in your urls.py and pass it the names of all
+ Views modules as strings.
+
+ """
+
+ all_views = view_classes_from_module(module_name)
+
+ view_urls = []
+ for ViewCls in all_views:
+ # automatically assign URL name for reverse(), it defaults to the
+ # class's name
+ url_name = getattr(ViewCls, 'url_name', ViewCls.__name__)
+
+ if settings.AUTH_LEVEL == 'STUDY':
+ unrestricted = url_name in STUDY_UNRESTRICTED_VIEWS
+ elif settings.AUTH_LEVEL == 'DEMO':
+ unrestricted = url_name in DEMO_UNRESTRICTED_VIEWS
+ else:
+ unrestricted = True
+
+ if unrestricted:
+ as_view = ViewCls.as_view()
+ else:
+ as_view = login_required(ViewCls.as_view())
+
+ url_pattern = ViewCls.url_pattern
+ if callable(url_pattern):
+ url_pattern = url_pattern()
+
+ view_urls.append(
+ urls.url(url_pattern, as_view, name=url_name)
+ )
+
+ return view_urls
+
+
+def extensions_urlpatterns():
+
+ urlpatterns = []
+
+ for url_module in get_extensions_modules('urls'):
+ urlpatterns += getattr(url_module, 'urlpatterns', [])
+
+ return urlpatterns
+
+
+def extensions_export_urlpatterns():
+ view_classes = get_extensions_data_export_views()
+ view_urls = []
+
+ for ViewCls in view_classes:
+ if settings.AUTH_LEVEL in {'DEMO', 'STUDY'}:
+ as_view = login_required(ViewCls.as_view())
+ else:
+ as_view = ViewCls.as_view()
+ view_urls.append(urls.url(ViewCls.url_pattern, as_view, name=ViewCls.url_name))
+
+ return view_urls
+
+
+def get_urlpatterns():
+
+ from django.contrib.auth.views import login, logout
+
+ urlpatterns = [
+ urls.url(r'^$', RedirectView.as_view(url='/demo', permanent=True)),
+ urls.url(
+ r'^accounts/login/$',
+ login,
+ {'template_name': 'otree/login.html'},
+ name='login_url',
+ ),
+ urls.url(
+ r'^accounts/logout/$',
+ logout,
+ {'next_page': 'DemoIndex'},
+ name='logout',
+ ),
+ ]
+
+ urlpatterns += staticfiles_urlpatterns()
+
+ used_names_in_url = set()
+ for app_name in settings.INSTALLED_OTREE_APPS:
+ models_module = common_internal.get_models_module(app_name)
+ name_in_url = models_module.Constants.name_in_url
+ if name_in_url in used_names_in_url:
+ msg = (
+ "App {} has Constants.name_in_url='{}', "
+ "which is already used by another app"
+ ).format(app_name, name_in_url)
+ raise ValueError(msg)
+
+ used_names_in_url.add(name_in_url)
+
+ views_module = common_internal.get_pages_module(app_name)
+ urlpatterns += url_patterns_from_game_module(
+ views_module.__name__, name_in_url)
+
+
+ urlpatterns += url_patterns_from_module('otree.views.participant')
+ urlpatterns += url_patterns_from_module('otree.views.demo')
+ urlpatterns += url_patterns_from_module('otree.views.admin')
+ urlpatterns += url_patterns_from_module('otree.views.room')
+ urlpatterns += url_patterns_from_module('otree.views.mturk')
+ urlpatterns += url_patterns_from_module('otree.views.export')
+
+ urlpatterns += extensions_urlpatterns()
+ urlpatterns += extensions_export_urlpatterns()
+
+ return urlpatterns
+
+
+urlpatterns = get_urlpatterns()
diff --git a/otree/views/__init__.py b/otree/views/__init__.py
new file mode 100644
index 0000000..e9b7e28
--- /dev/null
+++ b/otree/views/__init__.py
@@ -0,0 +1,27 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""Public API
+
+"""
+
+from importlib import import_module
+
+
+# NOTE: this imports the following submodules and then subclasses several
+# classes
+# importing is done via import_module rather than an ordinary import.
+# The only reason for this is to hide the base classes from IDEs like PyCharm,
+# so that those members/attributes don't show up in autocomplete,
+# including all the built-in django fields that an ordinary oTree programmer
+# will never need or want.
+# if this was a conventional Django project I wouldn't do it this way,
+# but because oTree is aimed at newcomers who may need more assistance from
+# their IDE,
+# I want to try this approach out.
+# this module is also a form of documentation of the public API.
+
+abstract = import_module('otree.views.abstract')
+
+WaitPage = abstract.WaitPage
+Page = abstract.Page
diff --git a/otree/views/abstract.py b/otree/views/abstract.py
new file mode 100644
index 0000000..86e0eb9
--- /dev/null
+++ b/otree/views/abstract.py
@@ -0,0 +1,1459 @@
+from django.core import signals
+from django.core.exceptions import (
+ PermissionDenied, SuspiciousOperation,
+)
+from django.http.multipartparser import MultiPartParserError
+from django.urls import get_resolver, get_urlconf
+
+import contextlib
+import importlib
+import json
+import logging
+import time
+
+import channels
+import otree.channels.utils as channel_utils
+import redis_lock
+import vanilla
+from django.conf import settings
+from django.core.urlresolvers import resolve
+from django.db.models import Max
+from django.http import HttpResponseRedirect, Http404, HttpResponse
+from django.shortcuts import get_object_or_404
+from django.template.response import TemplateResponse
+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 idmap
+import otree.common_internal
+import otree.constants_internal as constants
+import otree.db.idmap
+import otree.forms
+import otree.models
+import otree.timeout.tasks
+from otree.bots.bot import bot_prettify_post_data
+import otree.bots.browser as browser_bots
+from otree.common_internal import (
+ get_app_label_from_import_path, get_dotted_name, get_admin_secret_code,
+ DebugTable, BotError, wait_page_thread_lock, ResponseForException
+)
+from otree.models import (
+ Participant, Session, BasePlayer, BaseGroup, BaseSubsession)
+from otree.models_concrete import (
+ PageCompletion, CompletedSubsessionWaitPage,
+ CompletedGroupWaitPage, PageTimeout, UndefinedFormModel,
+ ParticipantLockModel,
+)
+from django.core.handlers.exception import handle_uncaught_exception
+
+
+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.
+ '''
+ if isinstance(exc, UNHANDLED_EXCEPTIONS):
+ '''copied from Django 1.11 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)
+
+ # Force a TemplateResponse to be rendered.
+ if not getattr(response, 'is_rendered', True) and callable(getattr(response, 'render', None)):
+ response = response.render()
+
+ return response
+
+NO_PARTICIPANTS_LEFT_MSG = (
+ "The maximum number of participants for this session has been exceeded.")
+
+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
+
+
+@contextlib.contextmanager
+def participant_scoped_db_lock(participant_code):
+ '''
+ prevent the same participant from executing the page twice
+ use this instead of a transaction because it's more lightweight.
+ transactions make it harder to reason about wait pages
+ '''
+ TIMEOUT = 10
+ start_time = time.time()
+ while time.time() - start_time < TIMEOUT:
+ updated_locks = ParticipantLockModel.objects.filter(
+ participant_code=participant_code,
+ locked=False
+ ).update(locked=True)
+ if not updated_locks:
+ time.sleep(0.2)
+ else:
+ try:
+ yield
+ finally:
+ ParticipantLockModel.objects.filter(
+ participant_code=participant_code,
+ ).update(locked=False)
+ return
+ exists = ParticipantLockModel.objects.filter(
+ participant_code=participant_code
+ ).exists()
+ if not exists:
+ raise Http404((
+ "This user ({}) does not exist in the database. "
+ "Maybe the database was recreated."
+ ).format(participant_code))
+
+ # could happen if the request that has the lock is paused somehow,
+ # e.g. in a debugger
+ raise Exception(
+ 'Another HTTP request has the lock for participant {}.'.format(
+ participant_code))
+
+
+def get_redis_lock(*, name='global'):
+ if otree.common_internal.USE_REDIS:
+ return redis_lock.Lock(
+ redis_client=otree.common_internal.get_redis_conn(),
+ name='OTREE_LOCK_{}'.format(name),
+ expire=10,
+ auto_renewal=True
+ )
+
+
+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(never_cache)
+ @method_decorator(cache_control(must_revalidate=True, max_age=0,
+ no_cache=True, no_store=True))
+ def dispatch(self, *args, **kwargs):
+ participant_code = kwargs.pop(constants.participant_code)
+
+ if otree.common_internal.USE_REDIS:
+ lock = redis_lock.Lock(
+ otree.common_internal.get_redis_conn(),
+ participant_code,
+ expire=60,
+ auto_renewal=True
+ )
+ else:
+ lock = participant_scoped_db_lock(participant_code)
+
+ with lock, otree.db.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 recreated."
+ ).format(participant_code)
+ raise Http404(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 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)
+
+ otree.db.idmap.save_objects()
+ if self.participant.is_browser_bot:
+ html = response.content.decode('utf-8')
+ # 2018-04-25: not sure why i didn't use an HTTP header.
+ # the if statement doesn't even seem to make a difference.
+ # shouldn't it always submit, if we're in a Page class?
+ # or why not just set an attribute directly on the response object?
+ # OTOH, this is pretty guaranteed to work. whereas i'm not sure
+ # that we can isolate the exact set of cases when we have to
+ # add the auto-submit flag (only GET requests, not wait pages,
+ # ...etc?)
+ if 'browser-bot-auto-submit' in html:
+ # needs to happen in GET, so that we can set the .html
+ # attribute on the bot.
+ browser_bots.set_attributes(
+ participant_code=self.participant.code,
+ request_path=self.request.path,
+ html=html,
+ )
+ return response
+
+ def get_context_data(self, **context):
+
+ context.update({
+ 'view': self,
+ # 2017-08-22: why do we need this?
+ 'object': getattr(self, 'object', None),
+ 'player': self.player,
+ 'group': self.group,
+ 'subsession': self.subsession,
+ 'session': self.session,
+ 'participant': self.participant,
+ 'Constants': self._Constants,
+
+ # doesn't exist on wait pages, so need getattr
+ 'timer_text': getattr(self, 'timer_text', None)
+ })
+
+ vars_for_template = {}
+ views_module = otree.common_internal.get_pages_module(
+ self.subsession._meta.app_config.name)
+ if hasattr(views_module, 'vars_for_all_templates'):
+ vars_for_template.update(views_module.vars_for_all_templates(self) or {})
+
+ try:
+ user_vars = self.vars_for_template()
+ 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 _get_debug_tables(self, vars_for_template):
+ try:
+ group_id = self.group.id_in_subsession
+ except:
+ group_id = ''
+
+ 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))
+
+ basic_info_table = DebugTable(
+ title='Basic info',
+ rows=[
+ ('ID in group', self.player.id_in_group),
+ ('Group', group_id),
+ ('Round number', self.subsession.round_number),
+ ('Participant', self.player.participant._id_in_session()),
+ ('Participant label', self.player.participant.label or ''),
+ ('Session code', self.session.code)
+ ]
+ )
+
+ tables.append(basic_info_table)
+
+ return tables
+
+ def _load_all_models(self):
+ '''Load all model instances into idmap cache'''
+ self.PlayerClass.objects.select_related(
+ 'group', 'subsession', 'session'
+ ).get(pk=self._player_pk)
+
+ def _is_displayed(self):
+ try:
+ return self.is_displayed()
+ except:
+ raise ResponseForException
+
+ @property
+ def player(self) -> BasePlayer:
+ # NOTE:
+ # these properties look in the idmap cache, so they don't touch
+ # the database if they are already loaded
+ return self.PlayerClass.objects.get(pk=self._player_pk)
+
+ @property
+ def group(self) -> BaseGroup:
+ '''can't cache self._group_pk because group can change'''
+ return self.player.group
+
+ @property
+ def subsession(self) -> BaseSubsession:
+ return self.SubsessionClass.objects.get(pk=self._subsession_pk)
+
+ @property
+ def participant(self) -> Participant:
+ return Participant.objects.get(pk=self._participant_pk)
+
+ @property
+ def session(self) -> Session:
+ return Session.objects.get(pk=self._session_pk)
+
+ _round_number = None
+ @property
+ def round_number(self):
+ if self._round_number is None:
+ self._round_number = self.subsession.round_number
+ return self._round_number
+
+ def set_attributes(self, participant, lazy=False):
+
+ player_lookup = participant.player_lookup()
+
+ app_name = player_lookup['app_name']
+
+ models_module = otree.common_internal.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_pk = player_lookup['player_pk']
+ self._subsession_pk = player_lookup['subsession_pk']
+ self._session_pk = player_lookup['session_pk']
+ self._participant_pk = participant.pk
+
+ # 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()
+
+ if not lazy:
+ self._load_all_models()
+ self.participant._round_number = self.player.round_number
+
+ # python 3.5 type hint
+ 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'''
+
+ self._Constants = original_view._Constants
+ self.PlayerClass = original_view.PlayerClass
+ 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._participant_pk = original_view._participant_pk
+
+ # is this needed?
+ self._index_in_pages = original_view._index_in_pages
+
+ def _increment_index_in_pages(self):
+ # when is this not the case?
+ assert self._index_in_pages == self.participant._index_in_pages
+
+ self._record_page_completion_time()
+ # 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
+
+ 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
+ url = self.participant._url_i_should_be_on()
+
+ Page = get_view_from_url(url)
+ page = Page()
+
+ page.set_attributes(self.participant, lazy=True)
+ 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
+ # this fixes a bug reported on 2016-11-04 on the mailing list
+ self.participant.save()
+ # you could just return page.dispatch(),
+ # but that could cause deep recursion
+
+ unvisited = page._get_unvisited_ids()
+ if not unvisited:
+ # we don't run after_all_players_arrive()
+ page._mark_completed()
+ participant_pk_set = set(
+ page._group_or_subsession.player_set.values_list(
+ 'participant__pk', flat=True))
+ page.send_completion_message(participant_pk_set)
+
+ def is_displayed(self):
+ return True
+
+ def _record_page_completion_time(self):
+
+ now = int(time.time())
+
+ last_page_timestamp = self.participant._last_page_timestamp
+ if last_page_timestamp is None:
+ logger.warning(
+ 'Participant {}: _last_page_timestamp is None'.format(
+ self.participant.code))
+ last_page_timestamp = now
+
+ seconds_on_page = now - last_page_timestamp
+
+ self.participant._last_page_timestamp = now
+ page_name = self.__class__.__name__
+
+ timeout_happened = bool(getattr(self, 'timeout_happened', False))
+
+ PageCompletion.objects.create(
+ app_name=self.subsession._meta.app_config.name,
+ page_index=self._index_in_pages,
+ page_name=page_name, time_stamp=now,
+ seconds_on_page=seconds_on_page,
+ subsession_pk=self.subsession.pk,
+ participant=self.participant,
+ session=self.session,
+ auto_submitted=timeout_happened)
+ self.participant.save()
+
+
+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 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()
+ form = self.get_form(instance=self.object)
+ context = self.get_context_data(form=form)
+ return self.render_to_response(context)
+
+ 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
+ raise ValueError(
+ "'{}' is an invalid value for form_model. "
+ "Try 'player' or 'group' instead.".format(form_model)
+ )
+ return form_model
+
+ def get_form_class(self):
+ try:
+ fields = self.get_form_fields()
+ except:
+ raise ResponseForException
+ if isinstance(fields, str):
+ # it could also happen with get_form_fields,
+ # but that is much less commonly used, so we word the error
+ # message just about form_fields.
+ msg = (
+ 'form_fields should be a list, not the string {fld}. '
+ 'Maybe you meant this: form_fields = [{fld}]'
+ ).format(fld=repr(fields))
+ raise ValueError(msg)
+ form_model = self._get_form_model()
+ if form_model is UndefinedFormModel and fields:
+ raise Exception(
+ 'Page "{}" defined form_fields but not form_model'.format(
+ self.__class__.__name__
+ )
+ )
+ return otree.forms.modelform_factory(
+ form_model, fields=fields,
+ form=otree.forms.ModelForm,
+ formfield_callback=otree.forms.formfield_callback)
+
+ 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[constants.redisplay_with_errors_http_header] = (
+ 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.get_next_post_data(
+ participant_code=self.participant.code)
+ if submission is None:
+ browser_bots.send_completion_message(
+ session_code=self.session.code,
+ participant_code=self.participant.code
+ )
+ return HttpResponse(BOT_COMPLETE_HTML_MESSAGE)
+ else:
+ # 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 = request.POST.get(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 = (
+ request.POST.get(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'):
+ raise BotError(
+ '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)))
+ # assigning to self.object is not really necessary
+ self.object = form.save()
+ else:
+ response = self.form_invalid(form)
+ 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()]
+ raise BotError(
+ 'Page "{}": Bot submission failed form validation: {} '
+ 'Check your bot in tests.py, '
+ 'then create a new session. '
+ 'Data submitted was: {}'.format(
+ PageName,
+ errors,
+ bot_prettify_post_data(post_data),
+ ))
+ 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:
+ raise BotError(
+ 'Page {}, SubmissionMustFail: '
+ 'Expected error_fields were {}, but actual '
+ 'error_fields are {}'.format(
+ PageName,
+ expected_error_fields,
+ actual_error_fields,
+ )
+ )
+ return response
+ try:
+ self.before_next_page()
+ except Exception as exc:
+ return response_for_exception(self.request, exc)
+
+ if self.participant.is_browser_bot:
+ if self._index_in_pages == self.participant._max_page_index:
+ # fixme: is it right to set html=''?
+ # could this break any asserts?
+ browser_bots.set_attributes(
+ participant_code=self.participant.code,
+ request_path=self.request.path,
+ html='',
+ )
+ submission = browser_bots.get_next_post_data(
+ participant_code=self.participant.code)
+ if submission is None:
+ browser_bots.send_completion_message(
+ session_code=self.session.code,
+ participant_code=self.participant.code
+ )
+ return HttpResponse(BOT_COMPLETE_HTML_MESSAGE)
+ else:
+ raise BotError(
+ 'Finished the last page, '
+ 'but the bot is still trying '
+ 'to submit more data ({}).'.format(submission)
+ )
+ 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_auto_submit_values(self):
+ # TODO: auto_submit_values deprecated on 2015-05-28
+ auto_submit_values = getattr(self, 'auto_submit_values', {})
+ timeout_submission = self.timeout_submission or auto_submit_values
+ 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': ''}
+ '''
+ auto_submit_values = self._get_auto_submit_values()
+
+ # 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 = auto_submit_values
+ elif form.errors:
+ auto_submit_values_to_use = {}
+ for field_name in form.errors:
+ auto_submit_values_to_use[field_name] = auto_submit_values[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):
+ return PageTimeout.objects.filter(
+ participant=self.participant,
+ page_index=self.participant._index_in_pages).exists()
+
+ _remaining_timeout_seconds = 'unset'
+ def remaining_timeout_seconds(self):
+
+ if self._remaining_timeout_seconds is not 'unset':
+ return self._remaining_timeout_seconds
+
+ try:
+ timeout_seconds = self.get_timeout_seconds()
+ except:
+ raise ResponseForException
+
+ if timeout_seconds is None:
+ # don't hit the DB at all
+ pass
+ else:
+ current_time = time.time()
+ expiration_time = current_time + timeout_seconds
+
+ timeout_object, created = PageTimeout.objects.get_or_create(
+ participant=self.participant,
+ page_index=self.participant._index_in_pages,
+ defaults={'expiration_time': expiration_time})
+
+ timeout_seconds = timeout_object.expiration_time - current_time
+ if created and otree.common_internal.USE_REDIS:
+ # 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.timeout.tasks.submit_expired_url.schedule(
+ (
+ self.participant.code,
+ 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
+
+ # task queue can't schedule tasks in the past
+ # at least 1 second from now
+ delay=max(1, timeout_seconds+8))
+ self._remaining_timeout_seconds = timeout_seconds
+ 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:")
+
+
+
+
+
+_MSG_Undefined_GetPlayersForGroup = (
+ 'You cannot reference self.player, self.group, or self.participant '
+ 'inside get_players_for_group.'
+)
+
+_MSG_Undefined_AfterAllPlayersArrive_Player = (
+ 'self.player and self.participant cannot be referenced '
+ 'inside after_all_players_arrive, '
+ 'which is executed only once '
+ 'for the entire group.'
+)
+
+_MSG_Undefined_AfterAllPlayersArrive_Group = (
+ 'self.group cannot be referenced inside after_all_players_arrive '
+ 'if wait_for_all_groups=True, '
+ 'because after_all_players_arrive() is executed only once '
+ 'for all groups in the subsession.'
+)
+
+class Undefined_AfterAllPlayersArrive_Player:
+ def __getattribute__(self, item):
+ raise AttributeError(_MSG_Undefined_AfterAllPlayersArrive_Player)
+
+ def __setattr__(self, item, value):
+ raise AttributeError(_MSG_Undefined_AfterAllPlayersArrive_Player)
+
+
+class Undefined_AfterAllPlayersArrive_Group:
+ def __getattribute__(self, item):
+ raise AttributeError(_MSG_Undefined_AfterAllPlayersArrive_Group)
+
+ def __setattr__(self, item, value):
+ raise AttributeError(_MSG_Undefined_AfterAllPlayersArrive_Group)
+
+
+class Undefined_GetPlayersForGroup:
+
+ def __getattribute__(self, item):
+ raise AttributeError(_MSG_Undefined_GetPlayersForGroup)
+
+ def __setattr__(self, item, value):
+ raise AttributeError(_MSG_Undefined_GetPlayersForGroup)
+
+
+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):
+ response = TemplateResponse(
+ self.request, self.get_template_names(), self.get_context_data())
+ response[constants.wait_page_http_header] = (
+ 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):
+ ## EARLY EXITS
+ if self._was_completed():
+ return self._save_and_flush_and_response_when_ready()
+ is_displayed = self._is_displayed()
+
+ if self.group_by_arrival_time and not 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()
+
+ if is_displayed and not self.group_by_arrival_time:
+ if self._get_unvisited_ids():
+ self.participant.is_on_wait_page = True
+ return self._get_wait_page()
+ ## END EARLY EXITS
+
+ with get_redis_lock(name='otree_waitpage') or wait_page_thread_lock:
+ # setting myself to _gbat_arrived = True should happen inside the lock
+ # because otherwise, another player might be able to see that I have arrived
+ # before I can run get_players_for_group, and they might end up grouping
+ # me. But because I am not in their group, they will not run AAPA for me
+ # so I will be considered 'already_grouped' and redirected to a wait page,
+ # and AAPA will never be run.
+
+ # also, it's simpler in general to have a broad lock.
+ # and easier to explain to users that it will be run as each player
+ # arrives.
+
+ if self.group_by_arrival_time:
+ self.player._gbat_arrived = True
+ # _last_request_timestamp is already set in set_attributes,
+ # but set it here just so we can guarantee
+ self.participant._last_request_timestamp = time.time()
+ # we call save_objects() below
+
+ otree.db.idmap.save_objects()
+ idmap.flush()
+
+ # 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
+ wp = type(self)() # type: WaitPage
+ wp.set_attributes_waitpage_clone(original_view=self)
+
+
+ # needs to happen before calculating participant_pk_set
+ # because this can change the group or PK
+ if wp.group_by_arrival_time:
+ wp._player_access_forbidden = Undefined_GetPlayersForGroup()
+ wp._participant_access_forbidden = Undefined_GetPlayersForGroup()
+ wp._group_access_forbidden = Undefined_GetPlayersForGroup()
+
+ # check if the player was already grouped.
+ # this is a 'check' of the check-then-act, so it needs to be
+ # inside the lock.
+ # It's possible that the player
+ # was grouped, but the Completion object does not exist yet,
+ # because aapa was not run.
+ already_grouped = type(self.player).objects.filter(
+ id=self.player.id).values_list(
+ '_gbat_grouped', flat=True)[0]
+ if already_grouped:
+ regrouped = False
+ else:
+ # 2017-10-29: what if the current player is not one
+ # of the people being regrouped?
+ regrouped = wp._gbat_try_to_regroup()
+
+ if not regrouped:
+ self.participant.is_on_wait_page = True
+ return self._get_wait_page()
+
+ wp._player_access_forbidden = None
+ wp._participant_access_forbidden = None
+ wp._group_access_forbidden = None
+
+ # the group membership might be modified
+ # in after_all_players_arrive, so calculate this first
+ participant_pk_set = set(
+ self._group_or_subsession.player_set
+ .values_list('participant__pk', flat=True))
+
+ if not self._was_completed():
+ if is_displayed:
+ # if any player can skip the wait page,
+ # then we shouldn't run after_all_players_arrive
+ # because if some players are able to proceed to the next page
+ # before after_all_players_arrive is run,
+ # then after_all_players_arrive is probably not essential.
+ # often, there are some wait pages that all players skip,
+ # because they should only be shown in certain rounds.
+ # maybe the fields that after_all_players_arrive depends on
+ # are null
+ # something to think about: ideally, should we check if
+ # all players skipped, or any player skipped?
+ # as a shortcut, we just check if is_displayed is true
+ # for the last player.
+
+ wp._player_access_forbidden = Undefined_AfterAllPlayersArrive_Player()
+ wp._participant_access_forbidden = Undefined_AfterAllPlayersArrive_Player()
+ if wp.wait_for_all_groups:
+ wp._group_access_forbidden = Undefined_AfterAllPlayersArrive_Group()
+ else:
+ wp._group_access_forbidden = None
+ wp._group_for_aapa = self.group
+ try:
+ wp.after_all_players_arrive()
+ except:
+ raise ResponseForException
+ # need to save to the results of after_all_players_arrive
+ # to the DB, before sending the completion message to other players
+ # this was causing a race condition on 2016-11-04
+ otree.db.idmap.save_objects()
+ # even if this player skips the page and after_all_players_arrive
+ # is not run, we need to indicate that the waiting players can advance
+ self._mark_completed()
+ self.send_completion_message(participant_pk_set)
+ return self._save_and_flush_and_response_when_ready()
+
+ def _gbat_try_to_regroup(self):
+ # if someone arrives within this many seconds of the last heartbeat of
+ # a player who drops out, they will be stuck.
+ # that sounds risky, but remember that
+ # if a player drops out at any point after that,
+ # the other players in the group will also be stuck.
+ # so the purpose of this is not to prevent dropouts that happen
+ # for random reasons, but specifically on a wait page,
+ # which is usually because someone gets stuck waiting for a long time.
+ # we don't want to make it too short, because that means the page
+ # would have to refresh very quickly, which could be disruptive.
+ STALE_THRESHOLD_SECONDS = 20
+
+ # count how many are re-grouped
+ waiting_players = list(self.subsession.player_set.filter(
+ _gbat_arrived=True,
+ _gbat_grouped=False,
+ participant___last_request_timestamp__gte=time.time()-STALE_THRESHOLD_SECONDS
+ ))
+
+ try:
+ players_for_group = self.get_players_for_group(waiting_players)
+ except:
+ raise ResponseForException
+
+ if not players_for_group:
+ return False
+ participant_ids = [p.participant.id for p in players_for_group]
+
+ group_id_in_subsession = self._gbat_next_group_id_in_subsession()
+
+ Constants = self._Constants
+
+ with otree.common_internal.transaction_except_for_sqlite():
+ for round_number in range(self.round_number, Constants.num_rounds+1):
+ subsession = self.subsession.in_round(round_number)
+
+ unordered_players = subsession.player_set.filter(
+ participant_id__in=participant_ids)
+
+ participant_ids_to_players = {
+ player.participant.id: player for player in unordered_players}
+
+ ordered_players_for_group = [
+ participant_ids_to_players[participant_id]
+ for participant_id in participant_ids]
+
+ if round_number == self.round_number:
+ for player in ordered_players_for_group:
+ player._gbat_grouped = True
+ player.save()
+
+ group = self.GroupClass.objects.create(
+ subsession=subsession, id_in_subsession=group_id_in_subsession,
+ session=self.session, round_number=round_number)
+ group.set_players(ordered_players_for_group)
+
+ # prune groups without players
+ # apparently player__isnull=True works, didn't know you could
+ # use this in a reverse direction.
+ subsession.group_set.filter(player__isnull=True).delete()
+ return True
+
+ def get_players_for_group(self, waiting_players):
+ Constants = self._Constants
+
+ if Constants.players_per_group is None:
+ raise AssertionError(
+ 'Page "{}": if using group_by_arrival_time, you must either set '
+ 'Constants.players_per_group to a value other than None, '
+ 'or define get_players_for_group() on the page.'.format(
+ self.__class__.__name__
+ )
+ )
+
+ # we're doing this inside a lock, so it actually should never be
+ # greater than, only equal.
+ if len(waiting_players) >= Constants.players_per_group:
+ return waiting_players[:Constants.players_per_group]
+
+ def _gbat_next_group_id_in_subsession(self):
+ # 2017-05-05: seems like this can result in id_in_subsession that
+ # doesn't start from 1.
+ # especially if you do group_by_arrival_time in every round
+ # is that a problem?
+ res = self.GroupClass.objects.filter(
+ session=self.session).aggregate(Max('id_in_subsession'))
+ return res['id_in_subsession__max'] + 1
+
+ _player_access_forbidden = None
+ @property
+ def player(self):
+ return self._player_access_forbidden or super().player
+
+ _group_access_forbidden = None
+ _group_for_aapa = None
+ @property
+ def group(self):
+ return self._group_access_forbidden or self._group_for_aapa or super().group
+
+ _participant_access_forbidden = None
+ @property
+ def participant(self):
+ return self._participant_access_forbidden or super().participant
+
+ @property
+ def _group_or_subsession(self):
+ return self.subsession if self.wait_for_all_groups else self.group
+
+ def _save_and_flush_and_response_when_ready(self):
+ # need to deactivate cache, in case after_all_players_arrive
+ # finished running after the moment set_attributes
+ # was called in this request.
+
+ # because in response_when_ready we will call
+ # increment_index_in_pages, which does a look-ahead and calls
+ # is_displayed() on the following pages. is_displayed() might
+ # depend on a field that is set in after_all_players_arrive
+ # so, need to clear the cache to ensure
+ # that we get fresh data.
+
+ # Note: i was never able to reproduce this myself -- just heard
+ # from Anthony N.
+ # and it shouldn't happen, because only the last player to visit
+ # can set is_ready(). if there is a request coming after that,
+ # then it must be someone refreshing the page manually.
+ # i guess we should protect against that.
+
+ # is_displayed() could also depend on a field on participant
+ # that was set on the wait page, so need to refresh participant,
+ # because it is passed as an arg to set_attributes().
+
+
+ otree.db.idmap.save_objects()
+ idmap.flush()
+ return self._response_when_ready()
+
+ def _was_completed(self):
+ if self.wait_for_all_groups:
+ return CompletedSubsessionWaitPage.objects.filter(
+ page_index=self._index_in_pages,
+ session=self.session,
+ ).exists()
+ return CompletedGroupWaitPage.objects.filter(
+ page_index=self._index_in_pages,
+ id_in_subsession=self.group.id_in_subsession,
+ session=self.session,
+ ).exists()
+
+ def _mark_completed(self):
+ # 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.
+ if self.wait_for_all_groups:
+ CompletedSubsessionWaitPage.objects.create(
+ page_index=self._index_in_pages,
+ session=self.session
+ )
+ else:
+ CompletedGroupWaitPage.objects.create(
+ page_index=self._index_in_pages,
+ id_in_subsession=self.group.id_in_subsession,
+ session=self.session
+ )
+
+ def send_completion_message(self, participant_pk_set):
+
+ if otree.common_internal.USE_REDIS:
+ # 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.timeout.tasks.ensure_pages_visited.schedule(
+ kwargs={
+ 'participant_pk_set': participant_pk_set},
+ delay=10)
+
+ # _group_or_subsession might be deleted
+ # in after_all_players_arrive, but it won't delete the cached model
+ channels_group_name = self.get_channels_group_name()
+ channels.Group(channels_group_name).send(
+ {'text': json.dumps(
+ {'status': 'ready'})}
+ )
+
+ def _channels_group_id_in_subsession(self):
+ if self.wait_for_all_groups:
+ return ''
+ return self.group.id_in_subsession
+
+ def get_channels_group_name(self):
+ if self.group_by_arrival_time:
+ return self._gbat_get_channels_group_name()
+ group_id_in_subsession = self._channels_group_id_in_subsession()
+
+ return channel_utils.wait_page_group_name(
+ session_pk=self._session_pk,
+ page_index=self._index_in_pages,
+ group_id_in_subsession=group_id_in_subsession)
+
+ def socket_url(self):
+ if self.group_by_arrival_time:
+ return self._gbat_socket_url()
+
+ group_id_in_subsession = self._channels_group_id_in_subsession()
+
+ return channel_utils.wait_page_path(
+ self._session_pk,
+ self._index_in_pages,
+ group_id_in_subsession
+ )
+
+
+ def _get_unvisited_ids(self):
+ """
+ Don't need a lock
+ """
+
+ participant_ids = set(
+ self._group_or_subsession.player_set.values_list(
+ 'participant_id', flat=True))
+
+ # essential query whose results can change from moment to moment
+ participant_data = Participant.objects.filter(
+ id__in=participant_ids
+ ).values('id', 'id_in_session', '_index_in_pages')
+
+ visited = []
+ unvisited = []
+ for p in participant_data:
+ if p['_index_in_pages'] < self._index_in_pages:
+ unvisited.append(p)
+ else:
+ visited.append(p)
+
+ # this is not essential to functionality.
+ # just for the display in the Monitor tab.
+ # so, we don't need a lock, even though there could be a race condition.
+ if 1 <= len(unvisited) <= 3:
+
+ unvisited_description = ', '.join(
+ 'P{}'.format(p['id_in_session']) for p in unvisited)
+
+ visited_ids = [p['id'] for p in visited]
+ Participant.objects.filter(
+ id__in=visited_ids
+ ).update(_waiting_for_ids=unvisited_description)
+
+ return {p['id'] for p in unvisited}
+
+ 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
+ '''
+ self.participant.is_on_wait_page = False
+ self.participant._waiting_for_ids = 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 ''
+
+ ## THE REST OF THIS CLASS IS GROUP_BY_ARRIVAL_TIME STUFF
+
+
+ def _gbat_get_channels_group_name(self):
+ return channel_utils.gbat_group_name(
+ session_pk=self._session_pk, page_index=self._index_in_pages,
+ )
+
+ def _gbat_socket_url(self):
+ return channel_utils.gbat_path(
+ self._session_pk, self._index_in_pages,
+ self.player._meta.app_config.name, self.player.id)
+
+
+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(**kwargs)
+ context.update({
+ 'session': self.session,
+ 'is_debug': settings.DEBUG,
+ 'request': self.request,
+ })
+ return context
+
+ def get_template_names(self):
+ return ['otree/admin/{}.html'.format(self.__class__.__name__)]
+
+ def dispatch(self, request, *args, **kwargs):
+ session_code = kwargs['code']
+ self.session = get_object_or_404(
+ otree.models.Session, code=session_code)
+ return super().dispatch(
+ request, *args, **kwargs)
+
+ def get_form_class(self):
+ """A drop-in replacement for
+ ``vanilla.model_views.GenericModelView.get_form_class``. The only
+ difference is that we use oTree's modelform_factory in order to always
+ get a floppyfied form back which supports richer widgets.
+ """
+ if self.form_class is not None:
+ return self.form_class
+
+ return otree.forms.modelform_factory(
+ self.model,
+ fields=self.fields,
+ formfield_callback=otree.forms.formfield_callback)
diff --git a/otree/views/admin.py b/otree/views/admin.py
new file mode 100644
index 0000000..cc41a32
--- /dev/null
+++ b/otree/views/admin.py
@@ -0,0 +1,797 @@
+import json
+import os
+import sys
+from collections import OrderedDict
+
+
+import channels
+import otree.bots.browser
+import otree.channels.utils as channel_utils
+import otree.common_internal
+import otree.export
+import otree.models
+import vanilla
+from django.conf import settings
+from django.contrib import messages
+from django.urls import reverse
+from django.http import HttpResponseRedirect, JsonResponse, HttpResponse, \
+ Http404
+from django.shortcuts import get_object_or_404
+from otree import forms
+from otree.common import RealWorldCurrency
+from otree.common_internal import (
+ create_session_and_redirect, missing_db_tables,
+ get_models_module, get_app_label_from_name, DebugTable,
+)
+from otree.forms import widgets
+from otree_startup import check_pypi_for_updates
+from otree.models import Participant, Session
+from otree.models_concrete import (
+ BrowserBotsLauncherSessionCode)
+from otree.session import SESSION_CONFIGS_DICT, create_session, SessionConfig
+from otree.views.abstract import GenericWaitPageMixin, AdminSessionPageMixin
+from django.db.models import Case, Value, When
+
+def pretty_name(name):
+ """Converts 'first_name' to 'first name'"""
+ if not name:
+ return ''
+ return name.replace('_', ' ')
+
+
+class CreateSessionForm(forms.Form):
+ session_configs = SESSION_CONFIGS_DICT.values()
+ session_config_choices = (
+ # use '' instead of None. '' seems to immediately invalidate the choice,
+ # rather than None which seems to be coerced to 'None'.
+ [('', '-----')] +
+ [(s['name'], s['display_name']) for s in session_configs])
+
+ session_config = forms.ChoiceField(
+ choices=session_config_choices, required=True)
+
+ num_participants = forms.IntegerField()
+
+ def __init__(self, *args, **kwargs):
+ for_mturk = kwargs.pop('for_mturk')
+ super().__init__(*args, **kwargs)
+ if for_mturk:
+ self.fields['num_participants'].label = "Number of MTurk workers"
+ self.fields['num_participants'].help_text = (
+ 'Since workers can return the HIT or drop out, '
+ 'some "spare" participants will be created: '
+ 'the oTree session will have '
+ '{} times more participants than the MTurk HIT. '
+ 'The number you enter in this field is number of '
+ 'workers required for your HIT.'.format(
+ settings.MTURK_NUM_PARTICIPANTS_MULTIPLE
+ )
+ )
+ else:
+ self.fields['num_participants'].label = "Number of participants"
+
+ def clean_num_participants(self):
+ session_config_name = self.cleaned_data.get('session_config')
+
+ # I think when this is checked, it's possible that basic validation
+ # for session_config_name was not done yet.
+ # when I tested it was None
+ # but maybe it could also be the empty string because that's what's
+ # explicitly put above.
+ if session_config_name:
+ lcm = SESSION_CONFIGS_DICT[session_config_name].get_lcm()
+ num_participants = self.cleaned_data['num_participants']
+ if num_participants % lcm:
+ raise forms.ValidationError(
+ 'Please enter a valid number of participants.'
+ )
+ return num_participants
+
+
+class CreateSession(vanilla.FormView):
+ form_class = CreateSessionForm
+ template_name = 'otree/admin/CreateSession.html'
+
+ url_pattern = r"^create_session/$"
+
+ def dispatch(self, request, *args, **kwargs):
+ # splinter makes request.GET.get('mturk') == ['1\\']
+ # no idea why
+ # so just see if it's non-empty
+ self.for_mturk = bool(self.request.GET.get('mturk'))
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ kwargs['configs'] = SESSION_CONFIGS_DICT.values()
+ return super().get_context_data(**kwargs)
+
+ def get_form(self, data=None, files=None, **kwargs):
+ kwargs['for_mturk'] = self.for_mturk
+ return super().get_form(data, files, **kwargs)
+
+ def form_valid(self, form):
+
+ session_config_name = form.cleaned_data['session_config']
+ session_kwargs = {
+ 'session_config_name': session_config_name,
+ 'for_mturk': self.for_mturk
+ }
+ if self.for_mturk:
+ session_kwargs['num_participants'] = (
+ form.cleaned_data['num_participants'] *
+ settings.MTURK_NUM_PARTICIPANTS_MULTIPLE
+ )
+
+ else:
+ session_kwargs['num_participants'] = (
+ form.cleaned_data['num_participants'])
+
+ # TODO:
+ # Refactor when we upgrade to push
+ if hasattr(self, "room"):
+ session_kwargs['room_name'] = self.room.name
+
+ post_data = self.request.POST
+ config = SESSION_CONFIGS_DICT[session_config_name]
+
+ edited_session_config_fields = {}
+
+ for field in config.editable_fields():
+ old_value = config[field]
+ html_field_name = config.html_field_name(field)
+
+ # int/float/decimal are set to required in HTML
+ # bool:
+ # - if unchecked, its key will be missing.
+ # - if checked, its value will be 'on'.
+ # str:
+ # - if blank, its value will be ''
+ new_value_str = post_data.get(html_field_name)
+ # don't use isinstance because that will catch bool also
+ if type(old_value) is int:
+ # in case someone enters 1.0 instead of 1
+ new_value = int(float(new_value_str))
+ else:
+ new_value = type(old_value)(new_value_str)
+ if old_value != new_value:
+ edited_session_config_fields[field] = new_value
+
+ # need to convert to float or string in order to serialize
+ # through channels
+ for k in ['participation_fee', 'real_world_currency_per_point']:
+ if k in edited_session_config_fields:
+ edited_session_config_fields[k] = float(
+ edited_session_config_fields[k])
+ session_kwargs['edited_session_config_fields'] = edited_session_config_fields
+
+
+ use_browser_bots = edited_session_config_fields.get('use_browser_bots')
+ if use_browser_bots is None:
+ use_browser_bots = config.get('use_browser_bots', False)
+
+ return create_session_and_redirect(
+ session_kwargs, use_browser_bots=use_browser_bots)
+
+
+class WaitUntilSessionCreated(GenericWaitPageMixin, vanilla.GenericView):
+
+ url_pattern = r"^WaitUntilSessionCreated/(?P.+)/$"
+
+ title_text = 'Creating session'
+ body_text = ''
+
+ def _is_ready(self):
+ try:
+ self.session = Session.objects.get(
+ _pre_create_id=self._pre_create_id,
+ ready_for_browser=True
+ )
+ return True
+ except Session.DoesNotExist:
+ return False
+
+ def _response_when_ready(self):
+ session = self.session
+ if session.is_for_mturk():
+ session_home_url = reverse(
+ 'MTurkCreateHIT', args=(session.code,)
+ )
+ # 2017-09-30: deleted a line about split screen here, because
+ # the only way to get split screen is to first open the session links
+ # page, then switch to split-screen mode
+ else:
+ session_home_url = reverse(
+ 'SessionStartLinks', args=(session.code,))
+
+ return HttpResponseRedirect(session_home_url)
+
+ def dispatch(self, request, *args, **kwargs):
+ self._pre_create_id = kwargs['pre_create_id']
+ if self._is_ready():
+ return self._response_when_ready()
+ return self._get_wait_page()
+
+ def socket_url(self):
+ return channel_utils.wait_for_session_path(self._pre_create_id)
+
+
+class SessionSplitScreen(AdminSessionPageMixin, vanilla.TemplateView):
+ '''Launch the session in fullscreen mode
+ only used in demo mode
+ '''
+
+ def get_context_data(self, **kwargs):
+ '''Get the URLs for the IFrames'''
+ context = super(SessionSplitScreen, self).get_context_data(**kwargs)
+ participant_urls = [
+ self.request.build_absolute_uri(participant._start_url())
+ for participant in self.session.get_participants()
+ ]
+ context.update({
+ 'session': self.session,
+ 'participant_urls': participant_urls
+ })
+ return context
+
+
+class SessionStartLinks(AdminSessionPageMixin, vanilla.TemplateView):
+
+ def get_context_data(self, **kwargs):
+ session = self.session
+ room = session.get_room()
+
+ context = super().get_context_data(**kwargs)
+
+ sqlite = settings.DATABASES['default']['ENGINE'].endswith('sqlite3')
+ context.update({
+ 'use_browser_bots': session.use_browser_bots(),
+ 'sqlite': sqlite,
+ 'runserver': 'runserver' in sys.argv or 'devserver' in sys.argv
+ })
+
+ session_start_urls = [
+ self.request.build_absolute_uri(participant._start_url())
+ for participant in session.get_participants()
+ ]
+
+ # TODO: Bot URLs, and a button to start the bots
+
+ if room:
+ context.update(
+ {
+ 'participant_urls':
+ room.get_participant_urls(self.request),
+ 'room_wide_url': room.get_room_wide_url(self.request),
+ 'session_start_urls': session_start_urls,
+ 'room': room,
+ 'collapse_links': True,
+ })
+ else:
+ anonymous_url = self.request.build_absolute_uri(
+ reverse(
+ 'JoinSessionAnonymously',
+ args=(session._anonymous_code,)
+ )
+ )
+
+ context.update({
+ 'participant_urls': session_start_urls,
+ 'anonymous_url': anonymous_url,
+ 'num_participants': len(session_start_urls),
+ 'splitscreen_mode_on': len(session_start_urls) <= 3
+ })
+
+ return context
+
+
+class SessionEditPropertiesForm(forms.ModelForm):
+ participation_fee = forms.RealWorldCurrencyField(
+ required=False,
+ # it seems that if this is omitted, the step defaults to an integer,
+ # meaninng fractional inputs are not accepted
+ widget=widgets._RealWorldCurrencyInput(attrs={'step': 0.01})
+ )
+ real_world_currency_per_point = forms.FloatField(
+ required=False
+ )
+
+ class Meta:
+ model = Session
+ fields = [
+ 'label',
+ 'experimenter_name',
+ 'comment',
+ ]
+
+
+class SessionEditProperties(AdminSessionPageMixin, vanilla.UpdateView):
+
+ # required for vanilla.UpdateView
+ lookup_field = 'code'
+ model = Session
+ form_class = SessionEditPropertiesForm
+ template_name = 'otree/admin/SessionEditProperties.html'
+
+ def get_form(self, data=None, files=None, **kwargs):
+ form = super(
+ SessionEditProperties, self
+ ).get_form(data, files, **kwargs)
+ config = self.session.config
+ form.fields[
+ 'participation_fee'
+ ].initial = config['participation_fee']
+ form.fields[
+ 'real_world_currency_per_point'
+ ].initial = config['real_world_currency_per_point']
+ if self.session.mturk_HITId:
+ form.fields['participation_fee'].widget.attrs['readonly'] = 'True'
+ return form
+
+ def get_success_url(self):
+ return reverse('SessionEditProperties', args=(self.session.code,))
+
+ def form_valid(self, form):
+ super(SessionEditProperties, self).form_valid(form)
+ participation_fee = form.cleaned_data[
+ 'participation_fee'
+ ]
+ real_world_currency_per_point = form.cleaned_data[
+ 'real_world_currency_per_point'
+ ]
+ config = self.session.config
+ if form.cleaned_data['participation_fee'] is not None:
+ config[
+ 'participation_fee'
+ # need to convert back to RealWorldCurrency, because easymoney
+ # MoneyFormField returns a decimal, not Money (not sure why)
+ ] = RealWorldCurrency(participation_fee)
+ if form.cleaned_data['real_world_currency_per_point'] is not None:
+ config[
+ 'real_world_currency_per_point'
+ ] = real_world_currency_per_point
+ self.session.save()
+ messages.success(self.request, 'Properties have been updated')
+ return HttpResponseRedirect(self.get_success_url())
+
+
+class SessionPayments(AdminSessionPageMixin, vanilla.TemplateView):
+
+ def get(self, *args, **kwargs):
+ response = super(SessionPayments, self).get(*args, **kwargs)
+ return response
+
+ def get_context_data(self, **kwargs):
+ session = self.session
+ # TODO: mark which ones are bots
+ participants = session.get_participants()
+ total_payments = 0.0
+ mean_payment = 0.0
+ if participants:
+ total_payments = sum(
+ part.payoff_plus_participation_fee() for part in participants
+ )
+ mean_payment = total_payments / len(participants)
+
+ context = super(SessionPayments, self).get_context_data(**kwargs)
+ context.update({
+ 'participants': participants,
+ 'total_payments': total_payments,
+ 'mean_payment': mean_payment,
+ 'participation_fee': session.config['participation_fee'],
+ })
+
+ return context
+
+
+def pretty_round_name(app_label, round_number):
+ app_label = pretty_name(app_label)
+ if round_number > 1:
+ return '{} [Round {}]'.format(app_label, round_number)
+ else:
+ return app_label
+
+
+class SessionData(AdminSessionPageMixin, vanilla.TemplateView):
+
+ def get_context_data(self, **kwargs):
+ session = self.session
+
+ rows = []
+
+ round_headers = []
+ model_headers = []
+ field_names = []
+
+ # field names for JSON response
+ field_names_json = []
+
+ for subsession in session.get_subsessions():
+ # can't use subsession._meta.app_config.name, because it won't work
+ # if the app is removed from SESSION_CONFIGS after the session is
+ # created.
+ columns_for_models, subsession_rows = otree.export.get_rows_for_live_update(subsession)
+
+ if not rows:
+ rows = subsession_rows
+ else:
+ for i in range(len(rows)):
+ rows[i].extend(subsession_rows[i])
+
+ round_colspan = 0
+ for model_name in ['player', 'group', 'subsession']:
+ colspan = len(columns_for_models[model_name])
+ model_headers.append((model_name.title(), colspan))
+ round_colspan += colspan
+
+ round_name = pretty_round_name(subsession._meta.app_label, subsession.round_number)
+
+ round_headers.append((round_name, round_colspan))
+
+ this_round_fields = []
+ this_round_fields_json = []
+ for model_name in ['Player', 'Group', 'Subsession']:
+ column_names = columns_for_models[model_name.lower()]
+ this_model_fields = [pretty_name(n) for n in column_names]
+ this_model_fields_json = [
+ '{}.{}.{}'.format(round_name, model_name, colname)
+ for colname in column_names
+ ]
+ this_round_fields.extend(this_model_fields)
+ this_round_fields_json.extend(this_model_fields_json)
+
+ field_names.extend(this_round_fields)
+ field_names_json.extend(this_round_fields_json)
+
+ # dictionary for json response
+ # will be used only if json request is done
+
+ self.context_json = []
+ for i, row in enumerate(rows, start=1):
+ d_row = OrderedDict()
+ # table always starts with participant 1
+ d_row['participant_label'] = 'P{}'.format(i)
+ for t, v in zip(field_names_json, row):
+ d_row[t] = v
+ self.context_json.append(d_row)
+
+ context = super().get_context_data(**kwargs)
+ context.update({
+ 'subsession_headers': round_headers,
+ 'model_headers': model_headers,
+ 'field_headers': field_names,
+ 'rows': rows})
+ return context
+
+ def get(self, request, *args, **kwargs):
+ context = self.get_context_data()
+ if self.request.META.get('CONTENT_TYPE') == 'application/json':
+ return JsonResponse(self.context_json, safe=False)
+ else:
+ return self.render_to_response(context)
+
+
+class SessionMonitor(AdminSessionPageMixin, vanilla.TemplateView):
+
+ def get_context_data(self, **kwargs):
+
+ field_names = otree.export.get_field_names_for_live_update(Participant)
+ display_names = {
+ '_id_in_session': 'ID in session',
+ 'code': 'Code',
+ 'label': 'Label',
+ '_current_page': 'Page',
+ '_current_app_name': 'App',
+ '_round_number': 'Round',
+ '_current_page_name': 'Page name',
+ 'status': 'Status',
+ '_last_page_timestamp': 'Time on page',
+ }
+
+ callable_fields = {'status', '_id_in_session', '_current_page'}
+
+ column_names = [display_names[col] for col in field_names]
+
+ context = super().get_context_data(**kwargs)
+ context['column_names'] = column_names
+
+ advance_users_button_text = (
+ "Advance the slowest user(s) by one page, "
+ "by forcing a timeout on their current page. "
+ )
+ context['advance_users_button_text'] = advance_users_button_text
+
+
+ participants = self.session.participant_set.filter(visited=True)
+ rows = []
+
+ for participant in participants:
+ row = {}
+ for field_name in field_names:
+ value = getattr(participant, field_name)
+ if field_name in callable_fields:
+ value = value()
+ row[field_name] = value
+ rows.append(row)
+
+ self.context_json = rows
+
+ return context
+
+ def get(self, request, *args, **kwargs):
+ context = self.get_context_data()
+ if self.request.META.get('CONTENT_TYPE') == 'application/json':
+ return JsonResponse(self.context_json, safe=False)
+ else:
+ return self.render_to_response(context)
+
+
+
+class SessionDescription(AdminSessionPageMixin, vanilla.TemplateView):
+
+ def get_context_data(self, **kwargs):
+ context = super(SessionDescription, self).get_context_data(**kwargs)
+ context['config'] = SessionConfig(self.session.config)
+ return context
+
+
+class AdminReportForm(forms.Form):
+ app_name = forms.ChoiceField(choices=[], required=False)
+ round_number = forms.IntegerField(required=False, min_value=1)
+
+ def __init__(self, *args, **kwargs):
+ self.session = kwargs.pop('session')
+ super().__init__(*args, **kwargs)
+
+ admin_report_apps = self.session._admin_report_apps()
+ num_rounds_list = self.session._admin_report_num_rounds_list()
+ self.rounds_per_app = dict(zip(admin_report_apps, num_rounds_list))
+ app_name_choices = []
+ for app_name in admin_report_apps:
+ label = '{} ({} rounds)'.format(
+ get_app_label_from_name(app_name), self.rounds_per_app[app_name]
+ )
+ app_name_choices.append((app_name, label))
+
+ self.fields['app_name'].choices = app_name_choices
+
+ def clean(self):
+ cleaned_data = super().clean()
+
+ apps_with_admin_report = self.session._admin_report_apps()
+
+ # can't use setdefault because the key will always exist even if the
+ # fields were empty.
+ # str default value is '',
+ # and int default value is None
+ if not cleaned_data['app_name']:
+ cleaned_data['app_name'] = apps_with_admin_report[0]
+
+ rounds_in_this_app = self.rounds_per_app[cleaned_data['app_name']]
+
+ round_number = cleaned_data['round_number']
+
+ if not round_number or round_number > rounds_in_this_app:
+ cleaned_data['round_number'] = rounds_in_this_app
+
+ self.data = cleaned_data
+
+ return cleaned_data
+
+
+class AdminReport(AdminSessionPageMixin, vanilla.TemplateView):
+
+ def get(self, request, *args, **kwargs):
+ form = AdminReportForm(data=request.GET, session=self.session)
+ # validate to get error messages
+ form.is_valid()
+ context = self.get_context_data(form=form)
+ return self.render_to_response(context)
+
+ def get_context_data(self, **kwargs):
+
+ cleaned_data = kwargs['form'].cleaned_data
+
+ models_module = get_models_module(cleaned_data['app_name'])
+ subsession = models_module.Subsession.objects.get(
+ session=self.session,
+ round_number=cleaned_data['round_number'],
+ )
+
+ context = {
+ 'subsession': subsession,
+ 'Constants': models_module.Constants,
+ 'session': self.session,
+ 'user_template': '{}/AdminReport.html'.format(
+ subsession._meta.app_config.label)
+ }
+
+ vars_for_admin_report = subsession.vars_for_admin_report() or {}
+ self.debug_tables = [
+ DebugTable(
+ title='vars_for_admin_report',
+ rows=vars_for_admin_report.items()
+ )
+ ]
+ # determine whether to display debug tables
+ self.is_debug = settings.DEBUG
+ context.update(vars_for_admin_report)
+
+ # this should take priority, in the event of a clash between
+ # a user-defined var and a built-in one
+ context.update(super().get_context_data(**kwargs))
+
+
+
+ return context
+
+
+class ServerCheck(vanilla.TemplateView):
+ template_name = 'otree/admin/ServerCheck.html'
+
+ url_pattern = r"^server_check/$"
+
+ def app_is_on_heroku(self):
+ return 'heroku' in self.request.get_host()
+
+ def worker_is_running(self):
+ if otree.common_internal.USE_REDIS:
+ redis_conn = otree.common_internal.get_redis_conn()
+ return otree.bots.browser.ping_bool(redis_conn, timeout=2)
+ else:
+ # the timeoutworker relies on Redis (Huey),
+ # so if Redis is not being used, the timeoutworker is not functional
+ return False
+
+ def get_context_data(self, **kwargs):
+ sqlite = settings.DATABASES['default']['ENGINE'].endswith('sqlite3')
+ debug = settings.DEBUG
+ regular_sentry = hasattr(settings, 'RAVEN_CONFIG')
+ heroku_sentry = os.environ.get('SENTRY_DSN')
+ sentry = regular_sentry or heroku_sentry
+ auth_level = settings.AUTH_LEVEL
+ auth_level_ok = settings.AUTH_LEVEL in {'DEMO', 'STUDY'}
+ heroku = self.app_is_on_heroku()
+ runserver = ('runserver' in sys.argv) or ('devserver' in sys.argv)
+ db_synced = not missing_db_tables()
+ pypi_results = check_pypi_for_updates()
+ worker_is_running = self.worker_is_running()
+
+ return {
+ 'sqlite': sqlite,
+ 'debug': debug,
+ 'sentry': sentry,
+ 'auth_level': auth_level,
+ 'auth_level_ok': auth_level_ok,
+ 'heroku': heroku,
+ 'runserver': runserver,
+ 'db_synced': db_synced,
+ 'pypi_results': pypi_results,
+ 'worker_is_running': worker_is_running,
+ }
+
+
+class OtreeCoreUpdateCheck(vanilla.View):
+
+ url_pattern = r"^version_cached/$"
+
+ # cached per process
+ results = None
+
+ def get(self, request, *args, **kwargs):
+ if OtreeCoreUpdateCheck.results is None:
+ OtreeCoreUpdateCheck.results = check_pypi_for_updates()
+ return JsonResponse(OtreeCoreUpdateCheck.results, safe=True)
+
+
+class CreateBrowserBotsSession(vanilla.View):
+
+ url_pattern = r"^create_browser_bots_session/$"
+
+ def get(self, request, *args, **kwargs):
+ # return browser bots check
+ sqlite = settings.DATABASES['default']['ENGINE'].endswith('sqlite3')
+
+ return JsonResponse({
+ 'sqlite': sqlite,
+ 'runserver': 'runserver' in sys.argv or 'devserver' in sys.argv
+ })
+
+ def post(self, request, *args, **kwargs):
+ num_participants = int(request.POST['num_participants'])
+ session_config_name = request.POST['session_config_name']
+ case_number = int(request.POST['case_number'])
+ session = create_session(
+ session_config_name=session_config_name,
+ num_participants=num_participants,
+ )
+ otree.bots.browser.initialize_session(
+ session_pk=session.pk, case_number=case_number)
+ BrowserBotsLauncherSessionCode.objects.update_or_create(
+ # i don't know why the update_or_create arg is called 'defaults'
+ # because it will update even if the instance already exists
+ # maybe for consistency with get_or_create
+ defaults={'code': session.code}
+ )
+ channels.Group('browser_bot_wait').send(
+ {'text': json.dumps({'status': 'session_ready'})}
+ )
+
+ return HttpResponse(session.code)
+
+
+class CloseBrowserBotsSession(vanilla.View):
+
+ url_pattern = r"^close_browser_bots_session/$"
+
+ def post(self, request, *args, **kwargs):
+ BrowserBotsLauncherSessionCode.objects.all().delete()
+ return HttpResponse('ok')
+
+
+class AdvanceSession(vanilla.View):
+
+ url_pattern = r'^AdvanceSession/(?P[a-z0-9]+)/$'
+
+ def post(self, *args, **kwargs):
+ session = get_object_or_404(
+ otree.models.Session, code=kwargs['session_code']
+ )
+ session.advance_last_place_participants()
+ return HttpResponse('ok')
+
+
+class Sessions(vanilla.ListView):
+ template_name = 'otree/admin/Sessions.html'
+
+ url_pattern = r"^sessions/$"
+
+ def dispatch(self, request, *args, **kwargs):
+ self.is_archive = self.request.GET.get('archived') == '1'
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ archived_sessions_exist = Session.objects.filter(archived=True).exists()
+ context.update({
+ 'is_archive': self.is_archive,
+ 'is_debug': settings.DEBUG,
+ 'archived_sessions_exist': archived_sessions_exist
+ })
+ return context
+
+ def get_queryset(self):
+ return Session.objects.filter(
+ is_demo=False, archived=self.is_archive).order_by('-pk')
+
+
+class ToggleArchivedSessions(vanilla.View):
+
+ url_pattern = r'^ToggleArchivedSessions/'
+
+ def post(self, request, *args, **kwargs):
+ code_list = request.POST.getlist('session')
+
+ (Session.objects.filter(code__in=code_list)
+ .update(archived=Case(
+ When(archived=True, then=Value(False)),
+ default=Value(True))
+ )
+ )
+
+ return HttpResponseRedirect(reverse('Sessions'))
+
+
+class DeleteSessions(vanilla.View):
+
+ url_pattern = r'^DeleteSessions/'
+
+ def dispatch(self, *args, **kwargs):
+ return super(DeleteSessions, self).dispatch(*args, **kwargs)
+
+ def post(self, request, *args, **kwargs):
+ for code in request.POST.getlist('session'):
+ session = get_object_or_404(
+ otree.models.Session, code=code
+ )
+ session.delete()
+ return HttpResponseRedirect(reverse('Sessions'))
\ No newline at end of file
diff --git a/otree/views/demo.py b/otree/views/demo.py
new file mode 100644
index 0000000..a3fa2cb
--- /dev/null
+++ b/otree/views/demo.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+
+from django.conf import settings
+from django.urls import reverse
+
+import vanilla
+from otree.session import SESSION_CONFIGS_DICT
+from otree.common_internal import create_session_and_redirect
+
+
+class DemoIndex(vanilla.TemplateView):
+
+ template_name = 'otree/DemoIndex.html'
+
+ url_pattern = r'^demo/$'
+
+ def get_context_data(self, **kwargs):
+ title = getattr(settings, 'DEMO_PAGE_TITLE', 'Demo')
+ intro_html = (
+ getattr(settings, 'DEMO_PAGE_INTRO_HTML', '') or
+ getattr(settings, 'DEMO_PAGE_INTRO_TEXT', ''))
+ context = super().get_context_data(**kwargs)
+
+ session_info = []
+ for session_config in SESSION_CONFIGS_DICT.values():
+ session_info.append(
+ {
+ 'name': session_config['name'],
+ 'display_name': session_config['display_name'],
+ 'url': reverse(
+ 'CreateDemoSession', args=(session_config['name'],)
+ ),
+ 'num_demo_participants': session_config[
+ 'num_demo_participants'
+ ]
+ }
+ )
+
+ context.update({
+ 'session_info': session_info,
+ 'title': title,
+ 'intro_html': intro_html,
+ 'is_debug': settings.DEBUG,
+ })
+ return context
+
+
+class CreateDemoSession(vanilla.GenericView):
+
+ url_pattern = r"^demo/(?P.+)/$"
+
+ def dispatch(self, request, *args, **kwargs):
+ session_config_name = kwargs['session_config']
+ try:
+ session_config = SESSION_CONFIGS_DICT[session_config_name]
+ except KeyError:
+ msg = 'Session config "{}" not found in settings.SESSION_CONFIGS.'
+ raise ValueError(msg.format(session_config_name)) from None
+ session_kwargs = {
+ 'is_demo': True,
+ 'session_config_name': session_config_name,
+ 'num_participants': session_config['num_demo_participants']
+ }
+
+ use_browser_bots = session_config.get('use_browser_bots', False)
+ return create_session_and_redirect(
+ session_kwargs, use_browser_bots=use_browser_bots)
diff --git a/otree/views/export.py b/otree/views/export.py
new file mode 100644
index 0000000..3eb0071
--- /dev/null
+++ b/otree/views/export.py
@@ -0,0 +1,148 @@
+import csv
+import datetime
+
+from django.http import HttpResponse
+from django.conf import settings
+
+import vanilla
+
+import otree.common_internal
+import otree.models
+import otree.export
+from otree.models.participant import Participant
+from otree.models.session import Session
+from otree.extensions import get_extensions_data_export_views
+from otree.models_concrete import ChatMessage
+
+
+class ExportIndex(vanilla.TemplateView):
+
+ template_name = 'otree/admin/Export.html'
+
+ url_pattern = r"^export/$"
+
+ def get_context_data(self, **kwargs):
+ context = super(ExportIndex, self).get_context_data(**kwargs)
+
+ context['db_is_empty'] = not Participant.objects.exists()
+
+ # can't use settings.INSTALLED_OTREE_APPS, because maybe the app
+ # was removed from SESSION_CONFIGS.
+ app_names_with_data = set()
+ for session in Session.objects.all():
+ for app_name in session.config['app_sequence']:
+ app_names_with_data.add(app_name)
+ context['app_names'] = app_names_with_data
+ context['chat_messages_exist'] = ChatMessage.objects.exists()
+ context['extensions_views'] = get_extensions_data_export_views()
+
+ return context
+
+
+class ExportAppDocs(vanilla.View):
+
+ url_pattern = r"^ExportAppDocs/(?P[\w.]+)/$"
+
+ def _doc_file_name(self, app_name):
+ return '{} - documentation ({}).txt'.format(
+ app_name,
+ datetime.date.today().isoformat()
+ )
+
+ def get(self, request, *args, **kwargs):
+ app_name = kwargs['app_name']
+ response = HttpResponse(content_type='text/plain')
+ response['Content-Disposition'] = 'attachment; filename="{}"'.format(
+ self._doc_file_name(app_name)
+ )
+ otree.export.export_docs(response, app_name)
+ return response
+
+
+def get_export_response(request, file_prefix):
+ if bool(request.GET.get('xlsx')):
+ content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ file_extension = 'xlsx'
+ else:
+ content_type = 'text/csv'
+ file_extension = 'csv'
+ response = HttpResponse(
+ content_type=content_type)
+ response['Content-Disposition'] = 'attachment; filename="{}"'.format(
+ '{} (accessed {}).{}'.format(
+ file_prefix,
+ datetime.date.today().isoformat(),
+ file_extension
+ ))
+ return response, file_extension
+
+
+class ExportApp(vanilla.View):
+
+ url_pattern = r"^ExportApp/(?P[\w.]+)/$"
+
+ def get(self, request, *args, **kwargs):
+
+ app_name = kwargs['app_name']
+ response, file_extension = get_export_response(request, app_name)
+ otree.export.export_app(app_name, response, file_extension=file_extension)
+ return response
+
+
+class ExportWide(vanilla.View):
+
+ url_pattern = r"^ExportWide/$"
+
+ def get(self, request, *args, **kwargs):
+ response, file_extension = get_export_response(
+ request, 'All apps - wide')
+ otree.export.export_wide(response, file_extension)
+ return response
+
+
+class ExportTimeSpent(vanilla.View):
+
+ url_pattern = r"^ExportTimeSpent/$"
+
+ def get(self, request, *args, **kwargs):
+ response = HttpResponse(content_type='text/csv')
+ response['Content-Disposition'] = 'attachment; filename="{}"'.format(
+ 'TimeSpent (accessed {}).csv'.format(
+ datetime.date.today().isoformat()
+ )
+ )
+ otree.export.export_time_spent(response)
+ return response
+
+
+class ExportChat(vanilla.View):
+
+ url_pattern = '^otreechatcore_export/$'
+
+ def get(request, *args, **kwargs):
+
+ response = HttpResponse(content_type='text/csv')
+ response['Content-Disposition'] = 'attachment; filename="{}"'.format(
+ 'Chat messages (accessed {}).csv'.format(
+ datetime.date.today().isoformat()
+ )
+ )
+
+ column_names = [
+ 'participant__session__code',
+ 'participant__session_id',
+ 'participant__id_in_session',
+ 'participant__code',
+ 'channel',
+ 'nickname',
+ 'body',
+ 'timestamp',
+ ]
+
+ rows = ChatMessage.objects.order_by('timestamp').values_list(*column_names)
+
+ writer = csv.writer(response)
+ writer.writerows([column_names])
+ writer.writerows(rows)
+
+ return response
\ No newline at end of file
diff --git a/otree/views/mturk.py b/otree/views/mturk.py
new file mode 100644
index 0000000..0748135
--- /dev/null
+++ b/otree/views/mturk.py
@@ -0,0 +1,445 @@
+#!/usr/bin/env python
+# encoding: utf-8
+
+import warnings
+import datetime
+from collections import defaultdict
+import sys
+import logging
+
+from django.conf import settings
+from django.contrib import messages
+from django.urls import reverse
+from django.http import HttpResponseRedirect, HttpResponseServerError
+from django.shortcuts import get_object_or_404
+
+from six.moves.urllib.parse import urlparse
+from six.moves.urllib.parse import urlunparse
+
+import vanilla
+
+try:
+ import boto3
+except ImportError:
+ boto3 = None
+
+import IPy
+
+import otree
+from otree import forms
+from otree.views.abstract import AdminSessionPageMixin
+from otree.checks.mturk import MTurkValidator
+from otree.forms import widgets
+from otree.common import RealWorldCurrency
+from otree.models import Session
+from decimal import Decimal
+
+logger = logging.getLogger('otree')
+
+import contextlib
+
+
+def get_mturk_client(*, use_sandbox=True):
+
+ if use_sandbox:
+ endpoint_url = 'https://mturk-requester-sandbox.us-east-1.amazonaws.com'
+ else:
+ endpoint_url = 'https://mturk-requester.us-east-1.amazonaws.com'
+ return boto3.client(
+ 'mturk',
+ aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
+ aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
+ endpoint_url=endpoint_url,
+ # if I specify endpoint_url without region_name, it complains
+ region_name='us-east-1',
+ )
+
+
+@contextlib.contextmanager
+def MTurkClient(*, use_sandbox=True, request):
+ '''Alternative to get_mturk_client, for when we need exception handling
+ in admin views, we should pass it, so that we can show the user the message
+ without crashing.
+ for participant-facing views and commandline tools, should use get_mturk_client.
+ '''
+ try:
+ yield get_mturk_client(use_sandbox=use_sandbox)
+ except Exception as exc:
+ logger.error('MTurk error', exc_info=True)
+ messages.error(request, str(exc), extra_tags='safe')
+
+
+def get_all_assignments(mturk_client, hit_id):
+ # Accumulate all relevant assignments, one page of results at
+ # a time.
+ assignments = []
+
+ args = dict(
+ HITId=hit_id,
+ # i think 100 is the max page size
+ MaxResults=100,
+ AssignmentStatuses=['Submitted', 'Approved', 'Rejected']
+ )
+
+ while True:
+ response = mturk_client.list_assignments_for_hit(**args)
+ if not response['Assignments']:
+ break
+ assignments.extend(response['Assignments'])
+ args['NextToken'] = response['NextToken']
+
+ return assignments
+
+
+def get_workers_by_status(mturk_client, hit_id):
+ all_assignments = get_all_assignments(mturk_client, hit_id)
+ workers_by_status = defaultdict(list)
+ for assignment in all_assignments:
+ workers_by_status[
+ assignment['AssignmentStatus']
+ ].append(assignment['WorkerId'])
+ return workers_by_status
+
+
+
+class MTurkCreateHITForm(forms.Form):
+
+ use_sandbox = forms.BooleanField(
+ required=False,
+ label='Use MTurk Sandbox (for development and testing)',
+ help_text=(
+ "If this box is checked, your HIT will not be published to "
+ "the MTurk live site, but rather to the MTurk Sandbox, "
+ "so you can test how it will look to MTurk workers."
+ ))
+ title = forms.CharField()
+ description = forms.CharField()
+ keywords = forms.CharField()
+ money_reward = forms.RealWorldCurrencyField(
+ # it seems that if this is omitted, the step defaults to an integer,
+ # meaninng fractional inputs are not accepted
+ widget=widgets._RealWorldCurrencyInput(attrs={'step': 0.01})
+ )
+ assignments = forms.IntegerField(
+ label="Number of assignments",
+ help_text="How many unique Workers do you want to work on the HIT?")
+ minutes_allotted_per_assignment = forms.IntegerField(
+ label="Minutes allotted per assignment",
+ help_text=(
+ "Number of minutes, that a Worker has to "
+ "complete the HIT after accepting it."
+ ))
+ expiration_hours = forms.FloatField(
+ label="Hours until HIT expiration",
+ help_text=(
+ "Number of hours after which the HIT "
+ "is no longer available for users to accept. "
+ ))
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields['assignments'].widget.attrs['readonly'] = True
+
+
+class MTurkCreateHIT(AdminSessionPageMixin, vanilla.FormView):
+ '''This view creates mturk HIT for session provided in request
+ AWS externalQuestion API is used to generate HIT.
+
+ '''
+ form_class = MTurkCreateHITForm
+
+ def in_public_domain(self, request, *args, **kwargs):
+ """This method validates if oTree are published on a public domain
+ because mturk need it
+
+ """
+ host = request.get_host().lower()
+ if ":" in host:
+ host = host.split(":", 1)[0]
+ if host == "localhost":
+ return False
+ try:
+ ip = IPy.IP(host)
+ return ip.iptype() == "PUBLIC"
+ except ValueError:
+ # probably is a public domain
+ return True
+
+ def get(self, request, *args, **kwargs):
+
+ mturk_settings = self.session.config['mturk_hit_settings']
+
+
+ initial = {
+ 'title': mturk_settings['title'],
+ 'description': mturk_settings['description'],
+ 'keywords': ', '.join(mturk_settings['keywords']),
+ 'money_reward': self.session.config['participation_fee'],
+ 'use_sandbox': settings.DEBUG,
+ 'minutes_allotted_per_assignment': (
+ mturk_settings['minutes_allotted_per_assignment']
+ ),
+ 'expiration_hours': mturk_settings['expiration_hours'],
+ 'assignments': self.session.mturk_num_participants,
+ }
+
+ form = self.get_form(initial=initial)
+ context = self.get_context_data(form=form)
+
+ url = self.request.build_absolute_uri(
+ reverse('MTurkCreateHIT', args=(self.session.code,))
+ )
+ parsed_url = urlparse(url)
+ https = parsed_url.scheme == 'https'
+ secured_url = urlunparse(parsed_url._replace(scheme='https'))
+
+ aws_keys_exist = bool(
+ getattr(settings, 'AWS_ACCESS_KEY_ID', None) and
+ getattr(settings, 'AWS_SECRET_ACCESS_KEY', None)
+ )
+ boto3_installed = bool(boto3)
+ mturk_ready = aws_keys_exist and boto3_installed and https
+ missing_next_button_warning = MTurkValidator(self.session).validation_message()
+
+ context.update({
+ # boto3 module must be imported, not None
+ 'boto3_installed': boto3_installed,
+ 'https': https,
+ 'aws_keys_exist': aws_keys_exist,
+ 'mturk_ready': mturk_ready,
+ 'runserver': ('runserver' in sys.argv) or ('devserver' in sys.argv),
+ 'secured_url': secured_url,
+ 'missing_next_button_warning': missing_next_button_warning
+ })
+
+ return self.render_to_response(context)
+
+ def post(self, request, *args, **kwargs):
+ form = self.get_form(
+ data=request.POST,
+ files=request.FILES
+ )
+ if not form.is_valid():
+ return self.form_invalid(form)
+ session = self.session
+ use_sandbox = 'use_sandbox' in form.data
+ # session can't be created
+ if (not self.in_public_domain(request, *args, **kwargs) and
+ not use_sandbox):
+ msg = (
+ 'Error: '
+ 'oTree must run on a public domain for Mechanical Turk'
+ '
')
+ return HttpResponseServerError(msg)
+ mturk_settings = session.config['mturk_hit_settings']
+ qualification_id = mturk_settings.get(
+ 'grant_qualification_id', None)
+ # verify that specified qualification type
+ # for preventing retakes exists on mturk server
+
+ url_landing_page = self.request.build_absolute_uri(
+ reverse('MTurkLandingPage', args=(session.code,)))
+
+ # updating schema from http to https
+ # this is compulsory for MTurk exteranlQuestion
+ # TODO: validate, that the server support https
+ # (heroku does support by default)
+ secured_url_landing_page = urlunparse(
+ urlparse(url_landing_page)._replace(scheme='https'))
+
+ # TODO: validate that there is enough money for the hit
+ money_reward = form.cleaned_data['money_reward']
+
+ # assign back to participation_fee, in case it was changed
+ # in the form
+ # need to convert back to RealWorldCurrency, because easymoney
+ # MoneyFormField returns a decimal, not Money (not sure why)
+ # see views.admin.EditSessionProperties
+ session.config['participation_fee'] = RealWorldCurrency(money_reward)
+
+ external_question = '''
+
+ {}
+ {}
+
+ '''.format(secured_url_landing_page, mturk_settings['frame_height'])
+
+ qualifications = mturk_settings.get('qualification_requirements')
+
+ if qualifications and not isinstance(qualifications[0], dict):
+ raise ValueError(
+ 'settings.py: You need to upgrade your MTurk qualification_requirements '
+ 'to the boto3 format. See the documentation.'
+ )
+
+ mturk_hit_parameters = {
+ 'Title': form.cleaned_data['title'],
+ 'Description': form.cleaned_data['description'],
+ 'Keywords': form.cleaned_data['keywords'],
+ 'Question': external_question,
+ 'MaxAssignments': form.cleaned_data['assignments'],
+ 'Reward': str(float(money_reward)),
+ 'QualificationRequirements': qualifications,
+ 'AssignmentDurationInSeconds': 60*form.cleaned_data['minutes_allotted_per_assignment'],
+ 'LifetimeInSeconds': int(60*60*form.cleaned_data['expiration_hours']),
+ # prevent duplicate HITs
+ 'UniqueRequestToken':'otree_{}'.format(session.code),
+ }
+
+ with MTurkClient(use_sandbox=use_sandbox, request=request) as mturk_client:
+ if qualification_id:
+ try:
+ mturk_client.get_qualification_type(
+ QualificationTypeId=qualification_id)
+ # it's RequestError, but
+ except Exception as exc:
+ if use_sandbox:
+ sandbox_note = (
+ 'You are currently using the sandbox, so you '
+ 'can only grant qualifications that were '
+ 'also created in the sandbox.')
+ else:
+ sandbox_note = (
+ 'You are using the MTurk live site, so you '
+ 'can only grant qualifications that were '
+ 'also created on the live site, and not the '
+ 'MTurk sandbox.')
+ msg = (
+ "In settings.py you specified qualification ID '{qualification_id}' "
+ "MTurk returned the following error: [{exc}] "
+ "Note: {sandbox_note}".format(
+ qualification_id=qualification_id,
+ exc=exc,
+ sandbox_note=sandbox_note))
+ messages.error(request, msg)
+ return HttpResponseRedirect(
+ reverse(
+ 'MTurkCreateHIT', args=(session.code,)))
+
+ hit = mturk_client.create_hit(**mturk_hit_parameters)['HIT']
+
+ session.mturk_HITId = hit['HITId']
+ session.mturk_HITGroupId = hit['HITGroupId']
+ session.mturk_use_sandbox = use_sandbox
+ session.save()
+
+ return HttpResponseRedirect(
+ reverse('MTurkCreateHIT', args=(session.code,)))
+
+
+class MTurkSessionPayments(AdminSessionPageMixin, vanilla.TemplateView):
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ session = self.session
+ published = bool(session.mturk_HITId)
+ context['published'] = published
+ if not published:
+ return context
+ with MTurkClient(use_sandbox=session.mturk_use_sandbox, request=self.request) as mturk_client:
+ workers_by_status = get_workers_by_status(
+ mturk_client,
+ session.mturk_HITId
+ )
+ participants_not_reviewed = session.participant_set.filter(
+ mturk_worker_id__in=workers_by_status['Submitted']
+ )
+ participants_approved = session.participant_set.filter(
+ mturk_worker_id__in=workers_by_status['Approved']
+ )
+ participants_rejected = session.participant_set.filter(
+ mturk_worker_id__in=workers_by_status['Rejected']
+ )
+
+ context.update({
+ 'participants_approved': participants_approved,
+ 'participants_rejected': participants_rejected,
+ 'participants_not_reviewed': participants_not_reviewed,
+ 'participation_fee': session.config['participation_fee'],
+ })
+
+ return context
+
+
+class PayMTurk(vanilla.View):
+
+ url_pattern = r'^PayMTurk/(?P[a-z0-9]+)/$'
+
+ def post(self, request, *args, **kwargs):
+ session = get_object_or_404(otree.models.Session,
+ code=kwargs['session_code'])
+ successful_payments = 0
+ failed_payments = 0
+ mturk_client = get_mturk_client(use_sandbox=session.mturk_use_sandbox)
+ # use worker ID instead of assignment ID. Because 2 workers can have
+ # the same assignment (if 1 starts it then returns it). we can't really
+ # block that.
+ # however, we can ensure that 1 worker does not get 2 assignments,
+ # by enforcing that the same worker is always assigned to the same participant.
+ for p in session.participant_set.filter(
+ mturk_worker_id__in=request.POST.getlist('workers')
+ ):
+ # need the try/except so that we try to pay the rest of the participants
+ payoff = p.payoff_in_real_world_currency()
+
+ try:
+ # approve assignment
+ mturk_client.approve_assignment(AssignmentId=p.mturk_assignment_id)
+ if payoff > 0:
+ mturk_client.send_bonus(
+ WorkerId=p.mturk_worker_id,
+ AssignmentId=p.mturk_assignment_id,
+ BonusAmount='{0:.2f}'.format(Decimal(payoff)),
+ # prevent duplicate payments
+ UniqueRequestToken='{}_{}'.format(p.mturk_worker_id, p.mturk_assignment_id),
+ # although the Boto documentation doesn't say so,
+ # this field is required. A user reported:
+ # "Value null at 'reason' failed to satisfy constraint:
+ # Member must not be null."
+ Reason='Thank you'
+ )
+ successful_payments += 1
+ except Exception as e:
+ msg = (
+ 'Could not pay {} because of an error communicating '
+ 'with MTurk: {}'.format(p._id_in_session(), str(e)))
+ messages.error(request, msg)
+ logger.error(msg)
+ failed_payments += 1
+ msg = 'Successfully made {} payments.'.format(successful_payments)
+ if failed_payments > 0:
+ msg += ' {} payments failed.'.format(failed_payments)
+ messages.warning(request, msg)
+ else:
+ messages.success(request, msg)
+ return HttpResponseRedirect(
+ reverse('MTurkSessionPayments', args=(session.code,)))
+
+
+class RejectMTurk(vanilla.View):
+
+ url_pattern = r'^RejectMTurk/(?P[a-z0-9]+)/$'
+
+ def post(self, request, *args, **kwargs):
+ session = get_object_or_404(Session,
+ code=kwargs['session_code'])
+ with MTurkClient(use_sandbox=session.mturk_use_sandbox,
+ request=request) as mturk_client:
+
+ for p in session.participant_set.filter(
+ mturk_worker_id__in=request.POST.getlist('workers')
+ ):
+ mturk_client.reject_assignment(
+ AssignmentId=p.mturk_assignment_id,
+ # The boto3 docs say this param is optional, but if I omit it, I get:
+ # An error occurred (ValidationException) when calling the RejectAssignment operation:
+ # 1 validation error detected: Value null at 'requesterFeedback'
+ # failed to satisfy constraint: Member must not be null
+ RequesterFeedback=''
+ )
+
+ messages.success(request, "You successfully rejected "
+ "selected assignments")
+ return HttpResponseRedirect(
+ reverse('MTurkSessionPayments', args=(session.code,)))
+
diff --git a/otree/views/participant.py b/otree/views/participant.py
new file mode 100644
index 0000000..4773011
--- /dev/null
+++ b/otree/views/participant.py
@@ -0,0 +1,359 @@
+import threading
+import time
+
+import django.utils.timezone
+import otree.common_internal
+import otree.constants_internal as constants
+import otree.models
+import otree.views.admin
+import otree.views.mturk
+import vanilla
+from django.urls import reverse
+from django.http import (
+ HttpResponse, HttpResponseRedirect,
+ HttpResponseNotFound
+)
+from django.shortcuts import get_object_or_404, render_to_response
+from django.template.response import TemplateResponse
+from django.utils.translation import ugettext as _
+from otree.common_internal import (
+ make_hash, add_params_to_url, get_redis_conn
+)
+import otree.channels.utils as channel_utils
+from otree.models import Participant, Session
+from otree.models_concrete import (
+ ParticipantRoomVisit, BrowserBotsLauncherSessionCode)
+from otree.room import ROOM_DICT
+from otree.views.abstract import (
+ GenericWaitPageMixin,
+ get_redis_lock, NO_PARTICIPANTS_LEFT_MSG)
+
+start_link_thread_lock = threading.RLock()
+
+class OutOfRangeNotification(vanilla.View):
+ name_in_url = 'shared'
+
+ def dispatch(self, request, *args, **kwargs):
+ return TemplateResponse(
+ request, 'otree/OutOfRangeNotification.html'
+ )
+
+ url_pattern = '^OutOfRangeNotification/$'
+
+
+class InitializeParticipant(vanilla.UpdateView):
+
+ url_pattern = r'^InitializeParticipant/(?P<{}>[a-z0-9]+)/$'.format(
+ constants.participant_code
+ )
+
+ def get(self, *args, **kwargs):
+
+ participant = get_object_or_404(
+ Participant,
+ code=kwargs[constants.participant_code]
+ )
+
+ if participant._index_in_pages == 0:
+ participant._index_in_pages = 1
+ participant.visited = True
+
+ # participant.label might already have been set
+ participant.label = participant.label or self.request.GET.get(
+ constants.participant_label
+ )
+ participant.ip_address = self.request.META['REMOTE_ADDR']
+
+ now = django.utils.timezone.now()
+ participant.time_started = now
+ participant._last_page_timestamp = time.time()
+
+ participant.save()
+ first_url = participant._url_i_should_be_on()
+ return HttpResponseRedirect(first_url)
+
+
+class MTurkLandingPage(vanilla.TemplateView):
+
+ def get_template_names(self):
+ hit_settings = self.session.config['mturk_hit_settings']
+ return [hit_settings['preview_template']]
+
+ url_pattern = r"^MTurkLandingPage/(?P[a-z0-9]+)/$"
+
+ def dispatch(self, request, *args, **kwargs):
+ session_code = kwargs['session_code']
+ self.session = get_object_or_404(
+ otree.models.Session, code=session_code
+ )
+ return super().dispatch(
+ request, *args, **kwargs
+ )
+
+ def get(self, request, *args, **kwargs):
+ assignment_id = (
+ self.request.GET['assignmentId']
+ if 'assignmentId' in self.request.GET else
+ ''
+ )
+ if assignment_id and assignment_id != 'ASSIGNMENT_ID_NOT_AVAILABLE':
+ url_start = reverse('MTurkStart', args=(self.session.code,))
+ url_start = add_params_to_url(url_start, {
+ 'assignmentId': self.request.GET['assignmentId'],
+ 'workerId': self.request.GET['workerId']})
+ return HttpResponseRedirect(url_start)
+
+ context = super().get_context_data(**kwargs)
+ return self.render_to_response(context)
+
+
+class MTurkStart(vanilla.View):
+
+ url_pattern = r"^MTurkStart/(?P[a-z0-9]+)/$"
+
+ def dispatch(self, request, *args, **kwargs):
+ session_code = kwargs['session_code']
+ self.session = get_object_or_404(
+ otree.models.Session, code=session_code
+ )
+ return super(MTurkStart, self).dispatch(
+ request, *args, **kwargs
+ )
+
+ def get(self, *args, **kwargs):
+ assignment_id = self.request.GET['assignmentId']
+ worker_id = self.request.GET['workerId']
+ qualification_id = self.session.config['mturk_hit_settings'].get('grant_qualification_id')
+ if qualification_id:
+ # don't pass request arg, because we don't want to show a message.
+ # using the fully qualified name because that seems to make mock.patch work
+ mturk_client = otree.views.mturk.get_mturk_client(
+ use_sandbox=self.session.mturk_use_sandbox)
+ # seems OK to assign this multiple times
+ mturk_client.associate_qualification_with_worker(
+ QualificationTypeId=qualification_id,
+ WorkerId=worker_id,
+ # Mturk complains if I omit IntegerValue
+ IntegerValue=1
+ )
+ try:
+ # just check if this worker already game, but
+ # don't filter for assignment, because maybe they already started
+ # and returned the previous assignment
+ # in this case, we should assign back to the same participant
+ # so that we don't get duplicates in the DB, and so people
+ # can't snoop and try the HIT first, then re-try to get a bigger bonus
+ participant = self.session.participant_set.get(
+ mturk_worker_id=worker_id)
+ except Participant.DoesNotExist:
+ with get_redis_lock(name='start_links') or start_link_thread_lock:
+ try:
+ participant = self.session.participant_set.filter(
+ visited=False
+ ).order_by('id')[0]
+ except IndexError:
+ return HttpResponseNotFound(NO_PARTICIPANTS_LEFT_MSG)
+
+ # 2014-10-17: needs to be here even if it's also set in
+ # the next view to prevent race conditions
+ # this needs to be inside the lock
+ participant.visited = True
+ participant.mturk_worker_id = worker_id
+ # reassign assignment_id, even if they are returning, because maybe they accepted
+ # and then returned, then re-accepted with a different assignment ID
+ # if it's their second time
+ participant.mturk_assignment_id = assignment_id
+ participant.save()
+ return HttpResponseRedirect(participant._start_url())
+
+
+def get_existing_or_new_participant(session, label):
+ if label:
+ try:
+ return session.participant_set.get(label=label)
+ except Participant.DoesNotExist:
+ pass
+ return session.participant_set.filter(
+ visited=False).order_by('id').first()
+
+
+def get_participant_with_cookie_check(session, cookies):
+ cookie_name = 'session_{}_participant'.format(session.code)
+ participant_code = cookies.get(cookie_name)
+ # this could return None
+ if participant_code:
+ return Participant.objects.filter(code=participant_code).first()
+ participant = session.participant_set.filter(
+ visited=False).order_by('id').first()
+ if participant:
+ cookies[cookie_name] = participant.code
+ return participant
+
+
+def participant_start_page_or_404(session, *, label, cookies=None):
+ '''pass request.session as an arg if you want to get/set a cookie'''
+ with get_redis_lock(name='start_links') or start_link_thread_lock:
+ if cookies is None:
+ participant = get_existing_or_new_participant(session, label)
+ else:
+ participant = get_participant_with_cookie_check(session, cookies)
+ if not participant:
+ return HttpResponseNotFound(NO_PARTICIPANTS_LEFT_MSG)
+
+ # needs to be here even if it's also set in
+ # the next view to prevent race conditions
+ participant.visited = True
+ if label:
+ participant.label = label
+ participant.save()
+
+ return HttpResponseRedirect(participant._start_url())
+
+
+class JoinSessionAnonymously(vanilla.View):
+
+ url_pattern = r'^join/(?P[a-z0-9]+)/$'
+
+ def get(self, *args, **kwargs):
+
+ anonymous_code = kwargs['anonymous_code']
+ session = get_object_or_404(
+ otree.models.Session, _anonymous_code=anonymous_code
+ )
+ label = self.request.GET.get('participant_label')
+ return participant_start_page_or_404(session, label=label)
+
+
+class AssignVisitorToRoom(GenericWaitPageMixin, vanilla.View):
+
+ url_pattern = r'^room/(?P\w+)/$'
+
+ def dispatch(self, request, *args, **kwargs):
+ self.room_name = kwargs['room']
+ try:
+ room = ROOM_DICT[self.room_name]
+ except KeyError:
+ return HttpResponseNotFound('Invalid room specified in url')
+
+ label = self.request.GET.get(
+ 'participant_label', ''
+ )
+
+ if room.has_participant_labels():
+ if label:
+ missing_label = False
+ invalid_label = label not in room.get_participant_labels()
+ else:
+ missing_label = True
+ invalid_label = False
+
+ # needs to be easy to re-enter label, in case we are in kiosk
+ # mode
+ if missing_label or invalid_label and not room.use_secure_urls:
+ return render_to_response(
+ "otree/RoomInputLabel.html",
+ {'invalid_label': invalid_label}
+ )
+
+ if room.use_secure_urls:
+ hash = self.request.GET.get('hash')
+ if hash != make_hash(label):
+ return HttpResponseNotFound(
+ 'Invalid hash parameter. use_secure_urls is True, '
+ 'so you must use the participant-specific URL.'
+ )
+
+ session = room.get_session()
+ if session is None:
+ self.tab_unique_id = otree.common_internal.random_chars_10()
+ self._socket_url = channel_utils.room_participant_path(
+ self.room_name,
+ label,
+ # random chars in case the participant has multiple tabs open
+ self.tab_unique_id
+ )
+ return render_to_response(
+ "otree/WaitPageRoom.html",
+ {
+ 'view': self, 'title_text': _('Please wait'),
+ 'body_text': _('Waiting for your session to begin')
+ }
+ )
+
+ if label:
+ cookies = None
+ else:
+ cookies = request.session
+
+
+ # 2017-08-02: changing the behavior so that even in a room without
+ # participant_label_file, 2 requests for the same start URL with same label
+ # will return the same participant. Not sure if the previous behavior
+ # (assigning to 2 different participants) was intentional or bug.
+ return participant_start_page_or_404(session, label=label, cookies=cookies)
+
+ def get_context_data(self, **kwargs):
+ return {
+ 'room': self.room_name,
+ }
+
+ def socket_url(self):
+ return self._socket_url
+
+ def redirect_url(self):
+ return self.request.get_full_path()
+
+
+class ParticipantRoomHeartbeat(vanilla.View):
+
+ url_pattern = r'^ParticipantRoomHeartbeat/(?P\w+)/$'
+
+ def get(self, request, *args, **kwargs):
+ # better not to return 404, because in practice, on Firefox,
+ # this was still being requested after the session started.
+ ParticipantRoomVisit.objects.filter(
+ tab_unique_id=kwargs['tab_unique_id']
+ ).update(last_updated=time.time())
+ return HttpResponse('')
+
+
+class ParticipantHeartbeatGBAT(vanilla.View):
+ url_pattern = r'^ParticipantHeartbeatGBAT/(?P\w+)/$'
+
+ def get(self, request, *args, **kwargs):
+ Participant.objects.filter(code=kwargs['participant_code']).update(
+ _last_request_timestamp=time.time())
+ return HttpResponse('')
+
+
+class BrowserBotStartLink(GenericWaitPageMixin, vanilla.View):
+
+ url_pattern = r'^browser_bot_start/$'
+
+ def dispatch(self, request, *args, **kwargs):
+ get_redis_conn()
+ session_info = BrowserBotsLauncherSessionCode.objects.first()
+ if session_info:
+ session = Session.objects.get(code=session_info.code)
+ with get_redis_lock(name='start_links') or start_link_thread_lock:
+ participant = session.participant_set.filter(
+ visited=False).order_by('id').first()
+ if not participant:
+ return HttpResponseNotFound(NO_PARTICIPANTS_LEFT_MSG)
+
+ # 2014-10-17: needs to be here even if it's also set in
+ # the next view to prevent race conditions
+ participant.visited = True
+ participant.save()
+
+ return HttpResponseRedirect(participant._start_url())
+ else:
+ ctx = {'view': self, 'title_text': 'Please wait',
+ 'body_text': 'Waiting for browser bots session to begin'}
+ return render_to_response("otree/WaitPage.html", ctx)
+
+ def socket_url(self):
+ return '/browser_bot_wait/'
+
+ def redirect_url(self):
+ return self.request.get_full_path()
diff --git a/otree/views/room.py b/otree/views/room.py
new file mode 100644
index 0000000..3ce240f
--- /dev/null
+++ b/otree/views/room.py
@@ -0,0 +1,121 @@
+import time
+
+import vanilla
+from django.urls import reverse
+from django.http import HttpResponseRedirect, JsonResponse
+from otree.channels import utils as channel_utils
+from otree.models_concrete import ParticipantRoomVisit
+from otree.room import ROOM_DICT
+from otree.views.admin import CreateSession
+
+
+class Rooms(vanilla.TemplateView):
+ template_name = 'otree/admin/Rooms.html'
+
+ url_pattern = r"^rooms/$"
+
+ def get_context_data(self, **kwargs):
+ return {'rooms': ROOM_DICT.values()}
+
+
+class RoomWithoutSession(CreateSession):
+ template_name = 'otree/admin/RoomWithoutSession.html'
+ room = None
+
+ url_pattern = r"^room_without_session/(?P.+)/$"
+
+ def dispatch(self, request, *args, **kwargs):
+ self.room = ROOM_DICT[kwargs['room_name']]
+ if self.room.has_session():
+ return HttpResponseRedirect(
+ reverse('RoomWithSession', args=[kwargs['room_name']]))
+ return super(RoomWithoutSession, self).dispatch(
+ request, *args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ context = {
+ 'participant_urls': self.room.get_participant_urls(self.request),
+ 'room_wide_url': self.room.get_room_wide_url(self.request),
+ 'room': self.room,
+ 'collapse_links': True,
+ }
+ kwargs.update(context)
+
+ return super(RoomWithoutSession, self).get_context_data(**kwargs)
+
+ def socket_url(self):
+ return channel_utils.room_admin_path(self.room.name)
+
+
+class RoomWithSession(vanilla.TemplateView):
+ template_name = 'otree/admin/RoomWithSession.html'
+ room = None
+
+ url_pattern = r"^room_with_session/(?P.+)/$"
+
+ def dispatch(self, request, *args, **kwargs):
+ self.room = ROOM_DICT[kwargs['room_name']]
+ if not self.room.has_session():
+ return HttpResponseRedirect(
+ reverse('RoomWithoutSession', args=[kwargs['room_name']]))
+ return super(RoomWithSession, self).dispatch(
+ request, *args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ context = {
+ 'participant_urls': self.room.get_participant_urls(self.request),
+ 'room_wide_url': self.room.get_room_wide_url(self.request),
+ 'session_url': reverse(
+ 'SessionMonitor',
+ args=(self.room.get_session().code,)),
+ 'room': self.room,
+ 'collapse_links': True,
+ }
+ kwargs.update(context)
+
+ return super(RoomWithSession, self).get_context_data(**kwargs)
+
+
+class CloseRoom(vanilla.View):
+ url_pattern = r"^CloseRoom/(?P.+)/$"
+
+ def post(self, request, *args, **kwargs):
+ room_name = kwargs['room_name']
+ self.room = ROOM_DICT[room_name]
+ self.room.set_session(None)
+ # in case any failed to be cleared through regular ws.disconnect
+ ParticipantRoomVisit.objects.filter(
+ room_name=room_name,
+ ).delete()
+ return HttpResponseRedirect(
+ reverse('RoomWithoutSession', args=[room_name]))
+
+
+class StaleRoomVisits(vanilla.View):
+
+ url_pattern = r'^StaleRoomVisits/(?P\w+)/$'
+
+ def get(self, request, *args, **kwargs):
+ stale_threshold = time.time() - 20
+ stale_participant_labels = ParticipantRoomVisit.objects.filter(
+ room_name=kwargs['room'],
+ last_updated__lt=stale_threshold
+ ).values_list('participant_label', flat=True)
+
+ # make json serializable
+ stale_participant_labels = list(stale_participant_labels)
+
+ return JsonResponse({'participant_labels': stale_participant_labels})
+
+
+class ActiveRoomParticipantsCount(vanilla.View):
+
+ url_pattern = r'^ActiveRoomParticipantsCount/(?P\w+)/$'
+
+ def get(self, request, *args, **kwargs):
+ count = ParticipantRoomVisit.objects.filter(
+ room_name=kwargs['room'],
+ last_updated__gte=time.time() - 20
+ ).count()
+
+ return JsonResponse({'count': count})
\ No newline at end of file
diff --git a/otree/widgets.py b/otree/widgets.py
new file mode 100644
index 0000000..e5fce24
--- /dev/null
+++ b/otree/widgets.py
@@ -0,0 +1,10 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# flake8: noqa
+
+# Keep backwards compatibility with older otree's
+# We moved the otree.widgets module to otree.forms.widgets
+# prior to adding otree.api in 2016, each models.py contained:
+# "from otree import widgets"
+
+from .forms.widgets import *
diff --git a/otree_startup/__init__.py b/otree_startup/__init__.py
new file mode 100644
index 0000000..45430cf
--- /dev/null
+++ b/otree_startup/__init__.py
@@ -0,0 +1,355 @@
+import json
+import logging
+import re
+import django.core.management
+import django.conf
+import os
+import sys
+from collections import OrderedDict, defaultdict
+from django.conf import settings
+from importlib import import_module
+from django.core.management import get_commands, load_command_class
+import django
+from django.apps import apps
+from django.core.management.base import BaseCommand
+from django.core.management.color import color_style
+from django.utils import autoreload, six
+from .settings import augment_settings
+import otree
+
+
+# REMEMBER TO ALSO UPDATE THE PROJECT TEMPLATE
+from otree_startup.settings import get_default_settings
+
+logger = logging.getLogger(__name__)
+
+
+def print_settings_not_found_error():
+ msg = (
+ "Cannot find oTree settings. "
+ "Please 'cd' to your oTree project folder, "
+ "which contains a settings.py file."
+ )
+ logger.warning(msg)
+
+
+def execute_from_command_line(*args, **kwargs):
+ '''
+ This is called if people use manage.py,
+ or if people use the otree script.
+ script_file is no longer used, but we need it for compat
+
+ Given the command-line arguments, this figures out which subcommand is
+ being run, creates a parser appropriate to that command, and runs it.
+ '''
+
+ argv = sys.argv
+
+ # so that we can patch it easily
+ settings = django.conf.settings
+
+ if len(argv) == 1:
+ # default command
+ argv.append('help')
+
+ subcommand = argv[1]
+
+ # Add the current directory to sys.path so that Python can find
+ # the settings module.
+ # when using "python manage.py" this is not necessary because
+ # the entry-point script's dir is automatically added to sys.path.
+ # but the 'otree' command script is located outside of the project
+ # directory.
+ if os.getcwd() not in sys.path:
+ sys.path.insert(0, os.getcwd())
+
+ # to match manage.py
+ # make it configurable so i can test it
+ # note: we will never get ImproperlyConfigured,
+ # because that only happens when DJANGO_SETTINGS_MODULE is not set
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings')
+ DJANGO_SETTINGS_MODULE = os.environ['DJANGO_SETTINGS_MODULE']
+
+ # some commands don't need settings.INSTALLED_APPS
+ try:
+ configure_settings(DJANGO_SETTINGS_MODULE)
+ except ImportSettingsError:
+ if subcommand in [
+ 'startproject',
+ 'help', 'version', '--help', '--version', '-h',
+ 'compilemessages', 'makemessages',
+ 'upgrade_my_code', 'update_my_code'
+ ]:
+ if not settings.configured:
+ settings.configure(**get_default_settings({}))
+ # need to differentiate between an ImportError because settings.py
+ # was not found, vs. ImportError because settings.py imports another
+ # module that is not found.
+ elif os.path.isfile('{}.py'.format(DJANGO_SETTINGS_MODULE)):
+ raise
+ else:
+ print_settings_not_found_error()
+ return
+
+ runserver_or_devserver = subcommand in ['runserver', 'devserver']
+
+ if runserver_or_devserver:
+ # apparently required by restart_with_reloader
+ # otherwise, i get:
+ # python.exe: can't open file 'C:\oTree\venv\Scripts\otree':
+ # [Errno 2] No such file or directory
+
+ # this doesn't work if you start runserver from another dir
+ # like python my_project/manage.py runserver. but that doesn't seem
+ # high-priority now.
+ sys.argv = ['manage.py'] + argv[1:]
+
+ # previous solution here was using subprocess.Popen,
+ # but changing it to modifying sys.argv changed average
+ # startup time on my machine from 2.7s to 2.3s.
+
+ # Start the auto-reloading dev server even if the code is broken.
+ # The hardcoded condition is a code smell but we can't rely on a
+ # flag on the command class because we haven't located it yet.
+
+ if runserver_or_devserver and '--noreload' not in argv:
+ try:
+ autoreload.check_errors(do_django_setup)()
+ except Exception:
+ # The exception will be raised later in the child process
+ # started by the autoreloader. Pretend it didn't happen by
+ # loading an empty list of applications.
+ apps.all_models = defaultdict(OrderedDict)
+ apps.app_configs = OrderedDict()
+ apps.apps_ready = apps.models_ready = apps.ready = True
+ else:
+ do_django_setup()
+
+ if subcommand in ['help', '--help', '-h'] and len(argv) == 2:
+ sys.stdout.write(main_help_text() + '\n')
+ elif subcommand == 'help' and len(argv) >= 3:
+ command_to_explain = argv[2]
+ fetch_command(command_to_explain).print_help('otree', command_to_explain)
+ elif subcommand in ("version", "--version"):
+ sys.stdout.write(otree.__version__ + '\n')
+ try:
+ pypi_updates_cli()
+ except:
+ pass
+ else:
+ fetch_command(subcommand).run_from_argv(argv)
+
+
+class ImportSettingsError(ImportError):
+ pass
+
+
+def configure_settings(DJANGO_SETTINGS_MODULE: str = 'settings'):
+ # settings could already be configured if we are testing
+ # execute_from_command_line
+ if django.conf.settings.configured:
+ return
+ try:
+ user_settings_module = import_module(DJANGO_SETTINGS_MODULE)
+ except ImportError:
+ raise ImportSettingsError
+ user_settings_dict = {}
+ user_settings_dict['BASE_DIR'] = os.path.dirname(
+ os.path.abspath(user_settings_module.__file__))
+ # this is how Django reads settings from a settings module
+ for setting_name in dir(user_settings_module):
+ if setting_name.isupper():
+ setting_value = getattr(user_settings_module, setting_name)
+ user_settings_dict[setting_name] = setting_value
+ augment_settings(user_settings_dict)
+ django.conf.settings.configure(**user_settings_dict)
+
+
+def do_django_setup():
+ try:
+ django.setup()
+ except Exception as exc:
+ import colorama
+ colorama.init(autoreset=True)
+ print_colored_traceback_and_exit(exc)
+
+
+def main_help_text() -> str:
+ """
+ Returns the script's main help text, as a string.
+ """
+ usage = [
+ "",
+ "Type 'otree help ' for help on a specific subcommand.",
+ "",
+ "Available subcommands:",
+ ]
+ commands_dict = defaultdict(lambda: [])
+ for name, app in six.iteritems(get_commands()):
+ if app == 'django.core':
+ app = 'django'
+ else:
+ app = app.rpartition('.')[-1]
+ commands_dict[app].append(name)
+ style = color_style()
+ for app in sorted(commands_dict.keys()):
+ usage.append("")
+ usage.append(style.NOTICE("[%s]" % app))
+ for name in sorted(commands_dict[app]):
+ usage.append(" %s" % name)
+
+ return '\n'.join(usage)
+
+
+def fetch_command(subcommand: str) -> BaseCommand:
+ """
+ Tries to fetch the given subcommand, printing a message with the
+ appropriate command called from the command line (usually
+ "django-admin" or "manage.py") if it can't be found.
+ override a few django commands in the case where settings not loaded.
+ hard to test this because we need to simulate settings not being
+ configured
+ """
+ if subcommand in ['startapp', 'startproject']:
+ command_module = import_module(
+ 'otree.management.commands.{}'.format(subcommand))
+ return command_module.Command()
+
+ commands = get_commands()
+ try:
+ app_name = commands[subcommand]
+ except KeyError:
+ sys.stderr.write(
+ "Unknown command: %r\nType 'otree help' for usage.\n"
+ % subcommand
+ )
+ sys.exit(1)
+ if isinstance(app_name, BaseCommand):
+ # If the command is already loaded, use it directly.
+ klass = app_name
+ else:
+ klass = load_command_class(app_name, subcommand)
+ return klass
+
+
+def check_pypi_for_updates() -> dict:
+ '''return a dict because it needs to be json serialized for the AJAX
+ response'''
+ # need to import it so it can be patched outside
+ import otree_startup
+ if not otree_startup.PYPI_CHECK_UPDATES:
+ return {'pypi_connection_error': True}
+ # import only if we need it
+ import requests
+
+ logging.getLogger("requests").setLevel(logging.WARNING)
+
+ try:
+ response = requests.get(
+ 'https://pypi.python.org/pypi/otree/json',
+ timeout=5,
+ )
+ assert response.ok
+ data = json.loads(response.content.decode())
+ except:
+ # could be requests.exceptions.Timeout
+ # or another error (404/500/firewall issue etc)
+ return {'pypi_connection_error': True}
+
+ semver_re = re.compile(r'^(\d+)\.(\d+)\.(\d+)$')
+
+ installed_dotted = otree.__version__
+ installed_match = semver_re.match(installed_dotted)
+
+ if installed_match:
+ # compare to the latest stable release
+
+ installed_tuple = [int(n) for n in installed_match.groups()]
+
+ releases = data['releases']
+ newest_tuple = [0, 0, 0]
+ newest_dotted = ''
+ for release in releases:
+ release_match = semver_re.match(release)
+ if release_match:
+ release_tuple = [int(n) for n in release_match.groups()]
+ if release_tuple > newest_tuple:
+ newest_tuple = release_tuple
+ newest_dotted = release
+ newest = newest_tuple
+ installed = installed_tuple
+
+ update_needed = (newest > installed and (
+ newest[0] > installed[0] or newest[1] > installed[1] or
+ newest[2] - installed[2] >= 8))
+
+ else:
+ # compare to the latest release, whether stable or not
+ newest_dotted = data['info']['version'].strip()
+ update_needed = newest_dotted != installed_dotted
+
+ if update_needed:
+ update_message = (
+ 'Your otree package is out-of-date '
+ '(version {}; latest is {}). '
+ 'You should upgrade with:\n '
+ '"pip3 install --upgrade otree"\n '
+ 'and update your requirements_base.txt.'.format(
+ installed_dotted, newest_dotted))
+ else:
+ update_message = ''
+ return {
+ 'pypi_connection_error': False,
+ 'update_needed': update_needed,
+ 'installed_version': installed_dotted,
+ 'newest_version': newest_dotted,
+ 'update_message': update_message,
+ }
+
+
+def pypi_updates_cli():
+ result = check_pypi_for_updates()
+ if result['pypi_connection_error']:
+ return
+ if result['update_needed']:
+ print(result['update_message'])
+
+
+PYPI_CHECK_UPDATES = True
+
+
+def print_colored_traceback_and_exit(exc):
+ import traceback
+ from termcolor import colored
+ import sys
+
+
+ def highlight(string):
+ return colored(string, 'white', 'on_blue')
+
+ # before we used BASE_DIR but apparently that setting was not set yet
+ # (not sure why)
+ # so use os.getcwd() instead.
+ # also, with BASE_DIR, I got "unknown command: devserver", as if
+ # the list of commands was not loaded.
+ current_dir = os.getcwd()
+
+ frames = traceback.extract_tb(sys.exc_info()[2])
+ new_frames = []
+ for frame in frames:
+ filename, lineno, name, line = frame
+ if current_dir in filename:
+ filename = highlight(filename)
+ line = highlight(line)
+ new_frames.append([filename, lineno, name, line])
+ # taken from django source?
+ lines = ['Traceback (most recent call last):\n']
+ lines += traceback.format_list(new_frames)
+ final_lines = traceback.format_exception_only(type(exc), exc)
+ # filename is only available for SyntaxError
+ if isinstance(exc, SyntaxError) and current_dir in exc.filename:
+ final_lines = [highlight(line) for line in final_lines]
+ lines += final_lines
+ for line in lines:
+ sys.stdout.write(line)
+ sys.exit(-1)
\ No newline at end of file
diff --git a/otree_startup/asgi.py b/otree_startup/asgi.py
new file mode 100644
index 0000000..ce776e0
--- /dev/null
+++ b/otree_startup/asgi.py
@@ -0,0 +1,27 @@
+
+import os
+import channels.asgi
+from . import configure_settings
+
+configure_settings()
+channel_layer = channels.asgi.get_channel_layer()
+
+from otree.common_internal import (
+ release_any_stale_locks, get_redis_conn # noqa
+)
+
+# clear any tasks in Huey DB, so they don't pile up over time,
+# especially if you run the server without the timeoutworker to consume the
+# tasks.
+# ideally we would only schedule a task in Huey if timeoutworker is running,
+# so that we don't pile up messages that never get consumed, but I don't know
+# how and when to check if Huey is running, in a performant way.
+# this code is also in timeoutworker.
+from huey.contrib.djhuey import HUEY # noqa
+HUEY.flush()
+
+from otree.bots.browser import redis_flush_bots # noqa
+redis_flush_bots(get_redis_conn())
+
+# needs to happen after Django setup
+release_any_stale_locks()
diff --git a/otree_startup/settings.py b/otree_startup/settings.py
new file mode 100644
index 0000000..ee69b43
--- /dev/null
+++ b/otree_startup/settings.py
@@ -0,0 +1,404 @@
+import os
+import os.path
+from django.contrib.messages import constants as messages
+from six.moves.urllib import parse as urlparse
+import dj_database_url
+
+DEFAULT_MIDDLEWARE = (
+ 'otree.middleware.CheckDBMiddleware',
+ 'whitenoise.middleware.WhiteNoiseMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.common.CommonMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+
+ # 2015-04-08: disabling SSLify until we make this work better
+ # 'sslify.middleware.SSLifyMiddleware',
+)
+
+
+def collapse_to_unique_list(*args):
+ """Create a new list with all elements from a given lists without reapeated
+ elements
+
+ """
+ combined = []
+ for arg in args:
+ for elem in arg or ():
+ if elem not in combined:
+ combined.append(elem)
+ return combined
+
+
+def get_default_settings(user_settings: dict):
+ '''
+ doesn't mutate user_settings, just reads from it
+ because some settings depend on others
+ '''
+ default_settings = {}
+
+ if user_settings.get('SENTRY_DSN'):
+ default_settings['RAVEN_CONFIG'] = {
+ 'dsn': user_settings['SENTRY_DSN'],
+ 'processors': ['raven.processors.SanitizePasswordsProcessor'],
+ }
+ # SentryHandler is very slow with URL resolving...can add 2 seconds
+ # to runserver startup! so only use when it's needed
+ sentry_handler_class = 'raven.contrib.django.raven_compat.handlers.SentryHandler'
+ else:
+ sentry_handler_class = 'logging.StreamHandler'
+
+ logging = {
+ 'version': 1,
+ 'disable_existing_loggers': False,
+ 'root': {
+ 'level': 'DEBUG',
+ 'handlers': ['console'],
+ },
+ 'formatters': {
+ 'verbose': {
+ 'format': '[%(levelname)s|%(asctime)s] %(name)s > %(message)s'
+ },
+ 'simple': {
+ 'format': '%(levelname)s %(message)s'
+ },
+ },
+ 'handlers': {
+ 'console': {
+ 'level': 'INFO',
+ 'class': 'logging.StreamHandler',
+ 'formatter': 'simple'
+ },
+ 'sentry': {
+ 'level': 'WARNING',
+ # perf issue. just use StreamHandler by default.
+ # only use the real sentry handler if a DSN exists.
+ # see below.
+ 'class': sentry_handler_class,
+
+ },
+ },
+ 'loggers': {
+ 'otree.test.core': {
+ 'handlers': ['console'],
+ 'propagate': False,
+ 'level': 'INFO',
+ },
+ # 2016-07-25: botworker seems to be sending messages to Sentry
+ # without any special configuration, not sure why.
+ # but, i should use a logger, because i need to catch exceptions
+ # in botworker so it keeps running
+ 'otree.test.browser_bots': {
+ 'handlers': ['sentry', 'console'],
+ 'propagate': False,
+ 'level': 'INFO',
+ },
+ 'django.request': {
+ 'handlers': ['console'],
+ 'propagate': True,
+ 'level': 'DEBUG',
+ },
+ # logger so that we can explicitly send certain warnings to sentry,
+ # without raising an exception.
+ # 2016-10-23: has not been used yet
+ 'otree.sentry': {
+ 'handlers': ['sentry'],
+ 'propagate': True,
+ 'level': 'DEBUG',
+ },
+ # log any error that occurs inside channels code
+ 'django.channels': {
+ 'handlers': ['sentry'],
+ 'propagate': True,
+ 'level': 'ERROR',
+ },
+ # This is required for exceptions inside Huey tasks to get logged
+ # to Sentry
+ 'huey.consumer': {
+ 'handlers': ['sentry', 'console'],
+ 'level': 'INFO'
+ },
+ # suppress the INFO message: 'raven is not configured (logging
+ # disabled).....', in case someone doesn't have a DSN
+ 'raven.contrib.django.client.DjangoClient': {
+ 'handlers': ['console'],
+ 'level': 'WARNING'
+ }
+
+
+ }
+ }
+
+ REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379')
+ redis_url_parsed = urlparse.urlparse(REDIS_URL)
+ BASE_DIR = user_settings.get('BASE_DIR', '')
+
+
+ default_settings.update({
+ 'DATABASES': {
+ 'default': dj_database_url.config(
+ default='sqlite:///' + os.path.join(BASE_DIR, 'db.sqlite3')
+ )
+ },
+ 'HUEY': {
+ 'name': 'otree-huey',
+ 'connection': {
+ 'host': redis_url_parsed.hostname,
+ 'port': redis_url_parsed.port,
+ 'password': redis_url_parsed.password
+ },
+ 'always_eager': False,
+ # I need a result store to retrieve the results of browser-bots
+ # tasks and pinging, even if the result is evaluated immediately
+ # (otherwise, calling the task returns None.
+ 'result_store': False,
+ 'consumer': {
+ 'workers': 1,
+ # 'worker_type': 'thread',
+ 'scheduler_interval': 5,
+ 'loglevel': 'warning',
+ },
+ },
+ # set to True so that if there is an error in an {% include %}'d
+ # template, it doesn't just fail silently. instead should raise
+ # an error (and send through Sentry etc)
+ 'STATIC_ROOT': os.path.join(BASE_DIR, '__temp_static_root'),
+ 'STATIC_URL': '/static/',
+ 'STATICFILES_STORAGE': (
+ 'whitenoise.django.GzipManifestStaticFilesStorage'
+ ),
+ 'ROOT_URLCONF': 'otree.urls',
+
+ 'TIME_ZONE': 'UTC',
+ 'USE_TZ': True,
+ 'ALLOWED_HOSTS': ['*'],
+
+ 'LOGGING': logging,
+
+ 'FORM_RENDERER': 'django.forms.renderers.TemplatesSetting',
+
+ 'REAL_WORLD_CURRENCY_CODE': 'USD',
+ 'REAL_WORLD_CURRENCY_DECIMAL_PLACES': 2,
+ 'USE_POINTS': True,
+ 'POINTS_DECIMAL_PLACES': 0,
+
+ # eventually can remove this,
+ # when it's present in otree-library
+ # that most people downloaded
+ 'USE_L10N': True,
+ 'SECURE_PROXY_SSL_HEADER': ('HTTP_X_FORWARDED_PROTO', 'https'),
+
+ # The project can override the routing.py used as entry point by
+ # setting CHANNEL_ROUTING.
+
+ 'CHANNEL_LAYERS': {
+ 'default': {
+ "BACKEND": "otree.channels.asgi_redis.RedisChannelLayer",
+ "CONFIG": {
+ "hosts": [REDIS_URL],
+ },
+ 'ROUTING': user_settings.get(
+ 'CHANNEL_ROUTING',
+ 'otree.channels.routing.channel_routing'),
+ },
+ # note: if I start using ChannelsLiveServerTestCase again,
+ # i might have to move this out,
+ # but because it doesn't work with multiple
+ # channel layers.
+ 'inmemory': {
+ "BACKEND": "asgiref.inmemory.ChannelLayer",
+ 'ROUTING': user_settings.get(
+ 'CHANNEL_ROUTING',
+ 'otree.channels.routing.channel_routing'),
+ },
+ },
+
+ # for convenience within oTree
+ 'REDIS_URL': REDIS_URL,
+
+ # since workers on Amazon MTurk can return the hit
+ # we need extra participants created on the
+ # server.
+ # The following setting is ratio:
+ # num_participants_server / num_participants_mturk
+ 'MTURK_NUM_PARTICIPANTS_MULTIPLE': 2,
+ 'LOCALE_PATHS': [
+ os.path.join(user_settings.get('BASE_DIR', ''), 'locale')
+ ],
+
+ # ideally this would be a per-app setting, but I don't want to
+ # pollute Constants. It doesn't make as much sense per session config,
+ # so I'm just going the simple route and making it a global setting.
+ 'BOTS_CHECK_HTML': True,
+ })
+ return default_settings
+
+
+
+class InvalidVariableError(Exception):
+ pass
+
+
+class InvalidTemplateVariable(str):
+ def get_error_message(self, variable_name_dotted: str):
+ bits = variable_name_dotted.split('.')
+ if len(bits) == 1:
+ return (
+ 'Invalid variable: "{}". '
+ 'Maybe you need to return it from vars_for_template()'
+ ).format(bits[0])
+
+ built_in_vars = [
+ 'player',
+ 'group',
+ 'subsession',
+ 'participant',
+ 'session',
+ 'Constants',
+ ]
+
+ if bits[0] in built_in_vars:
+ # This will not make sense in the admin report!
+ # but that's OK, it's a rare case, more advanced users
+ return (
+ '{} has no attribute "{}"'
+ ).format(bits[0], '.'.join(bits[1:]))
+ elif bits[0] == 'self' and bits[1] in built_in_vars:
+ return (
+ "Don't use 'self' in the template. "
+ "Just write: {}"
+ ).format('.'.join(bits[1:]))
+ else:
+ return 'Invalid variable: {}'.format(variable_name_dotted)
+
+ def __mod__(self, other):
+ '''hack that takes advantage of string_if_invalid's %s behavior'''
+ msg = self.get_error_message(str(other))
+ # "from None" because otherwise we get the full chain of
+ # checking if it's an attribute, dict key, list index ...
+ raise InvalidVariableError(msg) from None
+
+def augment_settings(settings: dict):
+ default_settings = get_default_settings(settings)
+ for k, v in default_settings.items():
+ settings.setdefault(k, v)
+
+ all_otree_apps_set = set()
+
+ for s in settings['SESSION_CONFIGS']:
+ for app in s['app_sequence']:
+ all_otree_apps_set.add(app)
+
+ all_otree_apps = list(all_otree_apps_set)
+
+ no_experiment_apps = [
+ 'otree',
+
+ # django.contrib.auth is slow, about 300ms.
+ # would be nice to only add it if there is actually a password
+ # i tried that but would need to add various complicated "if"s
+ # throughout the code
+ 'django.contrib.auth',
+ 'django.forms',
+ # needed for auth and very quick to load
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.messages',
+ # need to keep this around indefinitely for all the people who
+ # have {% load staticfiles %}
+ 'django.contrib.staticfiles',
+ 'channels',
+ 'huey.contrib.djhuey',
+ 'idmap',
+ ]
+
+ # these are slow...only add if we need them
+ if settings.get('RAVEN_CONFIG'):
+ no_experiment_apps.append('raven.contrib.django.raven_compat')
+
+ # order is important:
+ # otree unregisters User & Group, which are installed by auth.
+ # otree templates need to get loaded before the admin.
+ no_experiment_apps = collapse_to_unique_list(
+ no_experiment_apps,
+ settings['INSTALLED_APPS'],
+ settings.get('EXTENSION_APPS', [])
+ )
+
+ new_installed_apps = collapse_to_unique_list(
+ no_experiment_apps, all_otree_apps)
+
+ # TEMPLATES
+ _template_dir = os.path.join(settings['BASE_DIR'], '_templates')
+ if os.path.exists(_template_dir):
+ new_template_dirs = [_template_dir]
+ else:
+ new_template_dirs = []
+
+ # STATICFILES
+ _static_dir = os.path.join(settings['BASE_DIR'], '_static')
+
+ if os.path.exists(_static_dir):
+ additional_static_dirs = [_static_dir]
+ else:
+ additional_static_dirs = []
+
+ new_staticfiles_dirs = collapse_to_unique_list(
+ settings.get('STATICFILES_DIRS'),
+ additional_static_dirs,
+ )
+
+ new_middleware = collapse_to_unique_list(
+ DEFAULT_MIDDLEWARE,
+ settings.get('MIDDLEWARE_CLASSES'))
+
+ augmented_settings = {
+ 'INSTALLED_APPS': new_installed_apps,
+ 'TEMPLATES': [{
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': new_template_dirs,
+ 'OPTIONS': {
+ # 2016-10-08: setting template debug back to True,
+ # because if an included template has an error, we need
+ # to surface the error, rather than not showing the template.
+ # that's how I set it in d1cd00ebfd43c7eff408dea6363fd14bb90e7c06,
+ # but then in 2c10188b33f2ac36c046f4f0f8764e15d6a6fa81,
+ # i set this to False, but I'm not sure why and there is no
+ # note in the commit explaining why.
+ 'debug': True,
+ 'string_if_invalid': InvalidTemplateVariable("%s"),
+
+ # in Django 1.11, the cached template loader is applied
+ # automatically if template 'debug' is False,
+ # but for now we need 'debug' True because otherwise
+ # {% include %} fails silently.
+ # in django 2.1, we can remove:
+ # - the explicit 'debug': True
+ # - 'loaders' below
+ # - the patch in runserver.py
+ # as long as we set 'APP_DIRS': True
+ 'loaders': [
+ ('django.template.loaders.cached.Loader', [
+ 'django.template.loaders.filesystem.Loader',
+ 'django.template.loaders.app_directories.Loader',
+ ]),
+ ],
+ 'context_processors': (
+ # default ones in Django 1.8
+ 'django.contrib.auth.context_processors.auth',
+ 'django.template.context_processors.media',
+ 'django.template.context_processors.static',
+ 'django.contrib.messages.context_processors.messages',
+ 'django.template.context_processors.request',
+ )
+ },
+ }],
+ 'STATICFILES_DIRS': new_staticfiles_dirs,
+ 'MIDDLEWARE': new_middleware,
+ 'INSTALLED_OTREE_APPS': all_otree_apps,
+ 'MESSAGE_TAGS': {messages.ERROR: 'danger'},
+ 'LOGIN_REDIRECT_URL': 'Sessions',
+ }
+
+ settings.update(augmented_settings)
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..1fde7ac
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,40 @@
+asgi-redis==0.14.1
+asgiref==0.14.0
+autobahn==0.16.0
+channels==0.17.3
+colorama==0.3.7
+contextlib2==0.5.4
+daphne==0.14.3
+dj-database-url==0.4.1
+Django==1.11.2
+django-idmap==1.0.3
+django-vanilla-views==1.0.4
+honcho==0.7.1
+huey==1.2.0
+IPy==0.83
+msgpack-python==0.4.8
+otree-boto2-shim==0.3.2
+otree-core==0.0.0b1
+otree-save-the-change==1.1.3
+pbr==1.10.0
+py==1.4.31
+# can't upgrade to 3.0 because assert reinterp mode was removed
+# and now we have plain assertions. maybe can figure out
+pytest==2.9.2
+pytest-django==3.0.0
+python-redis-lock
+pytz==2017.3
+raven==5.25.0
+redis==2.10.5
+requests==2.11.1
+schema==0.6.2
+six==1.10.0
+termcolor==1.1.0
+Twisted==16.2.0
+txaio==2.5.1
+unicodecsv==0.14.1
+wheel==0.29.0
+whitenoise==3.2.1
+ws4py==0.3.5
+XlsxWriter==0.9.3
+zope.interface==4.2.0
\ No newline at end of file
diff --git a/requirements_mturk.txt b/requirements_mturk.txt
new file mode 100644
index 0000000..40c6ada
--- /dev/null
+++ b/requirements_mturk.txt
@@ -0,0 +1,19 @@
+# pyopenssl
+
+asn1crypto==0.22.0
+cffi==1.10.0
+cryptography==2.0
+idna==2.5
+pyasn1==0.2.3
+pyasn1-modules==0.0.9
+pycparser==2.18
+pyOpenSSL==17.2.0
+service-identity==17.0.0
+
+# boto3
+boto3==1.4.4
+botocore==1.5.86
+docutils==0.13.1
+jmespath==0.9.3
+python-dateutil==2.6.1
+s3transfer==0.1.10
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..861a9f5
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,5 @@
+[egg_info]
+tag_build =
+tag_date = 0
+tag_svn_revision = 0
+
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..ff1aada
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,83 @@
+import os
+import sys
+from setuptools import setup, find_packages
+
+# allow setup.py to be run from any path
+os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir)))
+
+import otree
+version = otree.__version__
+
+with open('README.rst', encoding='utf-8') as f:
+ README = f.read()
+
+with open('requirements.txt', encoding='utf-8') as f:
+ required = f.read().splitlines()
+
+with open('requirements_mturk.txt', encoding='utf-8') as f:
+ required_mturk = f.read().splitlines()
+
+
+
+if sys.argv[-1] == 'publish':
+
+ cmd = "python setup.py sdist upload"
+ print(cmd)
+ os.system(cmd)
+
+ cmd = 'git tag -a %s -m "version %s"' % (version, version)
+ print(cmd)
+ os.system(cmd)
+
+ cmd = "git push --tags"
+ print(cmd)
+ os.system(cmd)
+
+ sys.exit()
+
+if sys.version_info < (3, 5):
+ sys.exit('Error: This version of otree requires Python 3.5 or higher')
+
+
+setup(
+ name='otree',
+ version=version,
+ include_package_data=True,
+ license='MIT License',
+ # 2017-10-03: find_packages function works correctly, but tests
+ # are still being included in the package.
+ # not sure why. so instead i use
+ # recursive-exclude in MANIFEST.in.
+ packages=find_packages(),
+ description=(
+ 'oTree is a toolset that makes it easy to create and '
+ 'administer web-based social science experiments.'
+ ),
+ long_description=README,
+ url='http://otree.org/',
+ author='chris@otree.org',
+ author_email='chris@otree.org',
+ install_requires=required,
+ classifiers=[
+ 'Environment :: Web Environment',
+ 'Framework :: Django',
+ 'Framework :: Django :: 1.11',
+ 'Intended Audience :: Developers',
+ # example license
+ 'License :: OSI Approved :: MIT License',
+ 'Operating System :: OS Independent',
+ 'Programming Language :: Python',
+ 'Programming Language :: Python :: 3.6',
+ 'Topic :: Internet :: WWW/HTTP',
+ 'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
+ ],
+ entry_points={
+ 'console_scripts': [
+ 'otree=otree_startup:execute_from_command_line',
+ ],
+ },
+ zip_safe=False,
+ extras_require={
+ 'mturk': required_mturk
+ }
+)