diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b2ed33f --- /dev/null +++ b/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2014 Daniel Li Chen, Martin Walter Schonger, Christopher Wickens. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +The Licensee undertakes to mention the name oTree, the names of the licensors +(Daniel L. Chen, Martin Schonger and Christopher Wickens) and to cite the +following article in all publications in which results of experiments conducted +with the Software are published: Chen, Daniel L., Martin Schonger, and Chris Wickens. +2016. "oTree - An open-source platform for laboratory, online, and field experiments." +Journal of Behavioral and Experimental Finance, vol 9: 88-97. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..43965e2 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,12 @@ +include LICENSE +include README.rst +include requirements.txt +include requirements_mturk.txt +recursive-include otree/certs * +recursive-include otree/static * +recursive-include otree/templates * +recursive-include otree/project_template * +recursive-include otree/app_template/templates * +recursive-include otree/locale * +recursive-include otree *.pyi +recursive-exclude tests * \ No newline at end of file diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..687f919 --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,70 @@ +Metadata-Version: 1.1 +Name: otree +Version: 2.1.1 +Summary: oTree is a toolset that makes it easy to create and administer web-based social science experiments. +Home-page: http://otree.org/ +Author: chris@otree.org +Author-email: chris@otree.org +License: MIT License +Description: `Homepage`_ + + These are the core oTree libraries. + + Before you fork this project, keep in mind that otree-core is updated + frequently, and over time you might get upstream merge conflicts, as + your local project diverges from the oTree mainline version. + + Instead, consider creating a project with ``otree startproject`` and + making your modifications in an app, using oTree’s public API. You can + create custom URLs, channels, override settings, etc. + + Docs + ---- + + http://otree.readthedocs.io/en/latest/index.html + + Quickstart + ---------- + + Typical setup + ~~~~~~~~~~~~~ + + :: + + pip install -U otree + otree startproject oTree + cd oTree + otree devserver + + Core dev setup + ~~~~~~~~~~~~~~ + + If you are modifying otree-core locally, clone or download this repo, + then run this from the project root: + + :: + + pip install -e . + cd .. # or wherever you will start your project + otree startproject oTree + cd oTree + otree devserver + + + |Build Status| + + .. _Homepage: http://www.otree.org/ + + .. |Build Status| image:: https://travis-ci.org/oTree-org/otree-core.svg?branch=master + :target: https://travis-ci.org/oTree-org/otree-core +Platform: UNKNOWN +Classifier: Environment :: Web Environment +Classifier: Framework :: Django +Classifier: Framework :: Django :: 1.11 +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3.6 +Classifier: Topic :: Internet :: WWW/HTTP +Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..7b6e5ad --- /dev/null +++ b/README.rst @@ -0,0 +1,51 @@ +`Homepage`_ + +These are the core oTree libraries. + +Before you fork this project, keep in mind that otree-core is updated +frequently, and over time you might get upstream merge conflicts, as +your local project diverges from the oTree mainline version. + +Instead, consider creating a project with ``otree startproject`` and +making your modifications in an app, using oTree’s public API. You can +create custom URLs, channels, override settings, etc. + +Docs +---- + +http://otree.readthedocs.io/en/latest/index.html + +Quickstart +---------- + +Typical setup +~~~~~~~~~~~~~ + +:: + + pip install -U otree + otree startproject oTree + cd oTree + otree devserver + +Core dev setup +~~~~~~~~~~~~~~ + +If you are modifying otree-core locally, clone or download this repo, +then run this from the project root: + +:: + + pip install -e . + cd .. # or wherever you will start your project + otree startproject oTree + cd oTree + otree devserver + + +|Build Status| + +.. _Homepage: http://www.otree.org/ + +.. |Build Status| image:: https://travis-ci.org/oTree-org/otree-core.svg?branch=master + :target: https://travis-ci.org/oTree-org/otree-core \ No newline at end of file diff --git a/otree.egg-info/PKG-INFO b/otree.egg-info/PKG-INFO new file mode 100644 index 0000000..687f919 --- /dev/null +++ b/otree.egg-info/PKG-INFO @@ -0,0 +1,70 @@ +Metadata-Version: 1.1 +Name: otree +Version: 2.1.1 +Summary: oTree is a toolset that makes it easy to create and administer web-based social science experiments. +Home-page: http://otree.org/ +Author: chris@otree.org +Author-email: chris@otree.org +License: MIT License +Description: `Homepage`_ + + These are the core oTree libraries. + + Before you fork this project, keep in mind that otree-core is updated + frequently, and over time you might get upstream merge conflicts, as + your local project diverges from the oTree mainline version. + + Instead, consider creating a project with ``otree startproject`` and + making your modifications in an app, using oTree’s public API. You can + create custom URLs, channels, override settings, etc. + + Docs + ---- + + http://otree.readthedocs.io/en/latest/index.html + + Quickstart + ---------- + + Typical setup + ~~~~~~~~~~~~~ + + :: + + pip install -U otree + otree startproject oTree + cd oTree + otree devserver + + Core dev setup + ~~~~~~~~~~~~~~ + + If you are modifying otree-core locally, clone or download this repo, + then run this from the project root: + + :: + + pip install -e . + cd .. # or wherever you will start your project + otree startproject oTree + cd oTree + otree devserver + + + |Build Status| + + .. _Homepage: http://www.otree.org/ + + .. |Build Status| image:: https://travis-ci.org/oTree-org/otree-core.svg?branch=master + :target: https://travis-ci.org/oTree-org/otree-core +Platform: UNKNOWN +Classifier: Environment :: Web Environment +Classifier: Framework :: Django +Classifier: Framework :: Django :: 1.11 +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3.6 +Classifier: Topic :: Internet :: WWW/HTTP +Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content diff --git a/otree.egg-info/SOURCES.txt b/otree.egg-info/SOURCES.txt new file mode 100644 index 0000000..df3e4f0 --- /dev/null +++ b/otree.egg-info/SOURCES.txt @@ -0,0 +1,263 @@ +LICENSE +MANIFEST.in +README.rst +requirements.txt +requirements_mturk.txt +setup.py +otree/__init__.py +otree/api.py +otree/api.pyi +otree/apps.py +otree/asgi.py +otree/chat.py +otree/common.py +otree/common_internal.py +otree/conftest.py +otree/constants.py +otree/constants_internal.py +otree/export.py +otree/extensions.py +otree/matching.py +otree/middleware.py +otree/models_concrete.py +otree/room.py +otree/session.py +otree/settings.py +otree/strict_templates.py +otree/urls.py +otree/widgets.py +otree.egg-info/PKG-INFO +otree.egg-info/SOURCES.txt +otree.egg-info/dependency_links.txt +otree.egg-info/entry_points.txt +otree.egg-info/not-zip-safe +otree.egg-info/requires.txt +otree.egg-info/top_level.txt +otree/app_template/__init__.py +otree/app_template/models.py +otree/app_template/pages.py +otree/app_template/tests.py +otree/app_template/_builtin/__init__.py +otree/app_template/templates/app_name/MyPage.html +otree/app_template/templates/app_name/Results.html +otree/bots/__init__.py +otree/bots/bot.py +otree/bots/browser.py +otree/bots/browser_launcher.py +otree/bots/runner.py +otree/certs/development.crt +otree/certs/development.key +otree/channels/__init__.py +otree/channels/asgi_redis.py +otree/channels/consumers.py +otree/channels/routing.py +otree/channels/utils.py +otree/checks/__init__.py +otree/checks/mturk.py +otree/checks/templates.py +otree/currency/__init__.py +otree/currency/locale.py +otree/db/__init__.py +otree/db/idmap.py +otree/db/models.py +otree/db/serializedfields.py +otree/forms/__init__.py +otree/forms/fields.py +otree/forms/forms.py +otree/forms/widgets.py +otree/locale/ar/LC_MESSAGES/django.mo +otree/locale/ar/LC_MESSAGES/django.po +otree/locale/cs/LC_MESSAGES/django.mo +otree/locale/cs/LC_MESSAGES/django.po +otree/locale/de/LC_MESSAGES/django.mo +otree/locale/de/LC_MESSAGES/django.po +otree/locale/es/LC_MESSAGES/django.mo +otree/locale/es/LC_MESSAGES/django.po +otree/locale/fr/LC_MESSAGES/django.mo +otree/locale/fr/LC_MESSAGES/django.po +otree/locale/hu/LC_MESSAGES/django.mo +otree/locale/hu/LC_MESSAGES/django.po +otree/locale/it/LC_MESSAGES/django.mo +otree/locale/it/LC_MESSAGES/django.po +otree/locale/ja/LC_MESSAGES/django.mo +otree/locale/ja/LC_MESSAGES/django.po +otree/locale/ko/LC_MESSAGES/django.mo +otree/locale/ko/LC_MESSAGES/django.po +otree/locale/nb/LC_MESSAGES/django.mo +otree/locale/nb/LC_MESSAGES/django.po +otree/locale/nl/LC_MESSAGES/django.mo +otree/locale/nl/LC_MESSAGES/django.po +otree/locale/ru/LC_MESSAGES/django.mo +otree/locale/ru/LC_MESSAGES/django.po +otree/locale/zh_CN/LC_MESSAGES/django.mo +otree/locale/zh_CN/LC_MESSAGES/django.po +otree/locale/zh_Hans/LC_MESSAGES/django.mo +otree/locale/zh_Hans/LC_MESSAGES/django.po +otree/management/__init__.py +otree/management/cli.py +otree/management/commands/__init__.py +otree/management/commands/bots.py +otree/management/commands/botworker.py +otree/management/commands/browser_bots.py +otree/management/commands/create_session.py +otree/management/commands/devserver.py +otree/management/commands/django_test.py +otree/management/commands/ls_sessions.py +otree/management/commands/makemigrations.py +otree/management/commands/resetdb.py +otree/management/commands/runprodserver.py +otree/management/commands/runprodserver1of2.py +otree/management/commands/runprodserver2of2.py +otree/management/commands/runserver.py +otree/management/commands/startapp.py +otree/management/commands/startproject.py +otree/management/commands/test.py +otree/management/commands/timeoutworker.py +otree/management/commands/timeoutworkeronly.py +otree/management/commands/update_my_code.py +otree/management/commands/upgrade_my_code.py +otree/management/commands/webandworkers.py +otree/models/__init__.py +otree/models/fieldchecks.py +otree/models/group.py +otree/models/participant.py +otree/models/player.py +otree/models/session.py +otree/models/subsession.py +otree/models/varsmixin.py +otree/project_template/.gitignore +otree/project_template/Procfile +otree/project_template/README.md +otree/project_template/manage.py +otree/project_template/requirements.txt +otree/project_template/requirements_base.txt +otree/project_template/settings.py +otree/project_template/_static/global/instructions.css +otree/project_template/_static/global/matrix.css +otree/project_template/_templates/global/MTurkPreview.html +otree/project_template/_templates/global/Page.html +otree/static/favicon.ico +otree/static/robots.txt +otree/static/bootstrap4/css/bootstrap-grid.css +otree/static/bootstrap4/css/bootstrap-grid.css.map +otree/static/bootstrap4/css/bootstrap-grid.min.css +otree/static/bootstrap4/css/bootstrap-grid.min.css.map +otree/static/bootstrap4/css/bootstrap-reboot.css +otree/static/bootstrap4/css/bootstrap-reboot.css.map +otree/static/bootstrap4/css/bootstrap-reboot.min.css +otree/static/bootstrap4/css/bootstrap-reboot.min.css.map +otree/static/bootstrap4/css/bootstrap.css +otree/static/bootstrap4/css/bootstrap.css.map +otree/static/bootstrap4/css/bootstrap.min.css +otree/static/bootstrap4/css/bootstrap.min.css.map +otree/static/bootstrap4/js/bootstrap.bundle.js +otree/static/bootstrap4/js/bootstrap.bundle.js.map +otree/static/bootstrap4/js/bootstrap.bundle.min.js +otree/static/bootstrap4/js/bootstrap.bundle.min.js.map +otree/static/bootstrap4/js/bootstrap.js +otree/static/bootstrap4/js/bootstrap.js.map +otree/static/bootstrap4/js/bootstrap.min.js +otree/static/bootstrap4/js/bootstrap.min.js.map +otree/static/glyphicons/clock.png +otree/static/glyphicons/cloud.png +otree/static/glyphicons/cogwheel.png +otree/static/glyphicons/delete.png +otree/static/glyphicons/download-alt.png +otree/static/glyphicons/eye-open.png +otree/static/glyphicons/folder-closed.png +otree/static/glyphicons/link.png +otree/static/glyphicons/list-alt.png +otree/static/glyphicons/pencil.png +otree/static/glyphicons/plus.png +otree/static/glyphicons/pushpin.png +otree/static/glyphicons/refresh.png +otree/static/glyphicons/stats.png +otree/static/glyphicons/usd.png +otree/static/otree/css/page.css +otree/static/otree/css/table.css +otree/static/otree/css/theme.css +otree/static/otree/js/init_widgets.js +otree/static/otree/js/jquery-3.2.1.min.js +otree/static/otree/js/jquery.animate-colors-min.js +otree/static/otree/js/jquery.animate-colors.js +otree/static/otree/js/jquery.color-2.1.2.min.js +otree/static/otree/js/jquery.countdown.min.js +otree/static/otree/js/jquery.timeago.js +otree/static/otree/js/jsondiffpatch.min.js +otree/static/otree/js/jsondiffpatch.min.map +otree/static/otree/js/page-websocket-redirect.js +otree/static/otree/js/reconnecting-websocket.js +otree/static/otree/js/reconnecting-websocket.min.js +otree/static/otree/js/table-utils.js +otree/templates/500.html +otree/templates/django/forms/widgets/attrs.html +otree/templates/django/forms/widgets/multiple_input.html +otree/templates/global/Base.html +otree/templates/global/Page.html +otree/templates/otree/Base.html +otree/templates/otree/BaseAdmin.html +otree/templates/otree/DemoIndex.html +otree/templates/otree/FormPage.html +otree/templates/otree/MTurkPreview.html +otree/templates/otree/OutOfRangeNotification.html +otree/templates/otree/Page.html +otree/templates/otree/RoomInputLabel.html +otree/templates/otree/WaitPage.html +otree/templates/otree/WaitPageRoom.html +otree/templates/otree/login.html +otree/templates/otree/admin/AdminReport.html +otree/templates/otree/admin/CreateSession.html +otree/templates/otree/admin/Export.html +otree/templates/otree/admin/MTurkCreateHIT.html +otree/templates/otree/admin/MTurkSessionPayments.html +otree/templates/otree/admin/RoomWithSession.html +otree/templates/otree/admin/RoomWithoutSession.html +otree/templates/otree/admin/Rooms.html +otree/templates/otree/admin/ServerCheck.html +otree/templates/otree/admin/Session.html +otree/templates/otree/admin/SessionData.html +otree/templates/otree/admin/SessionDescription.html +otree/templates/otree/admin/SessionEditProperties.html +otree/templates/otree/admin/SessionMonitor.html +otree/templates/otree/admin/SessionPayments.html +otree/templates/otree/admin/SessionSplitScreen.html +otree/templates/otree/admin/SessionStartLinks.html +otree/templates/otree/admin/Sessions.html +otree/templates/otree/forms/errors.html +otree/templates/otree/forms/moneyinput.html +otree/templates/otree/forms/radio_select_horizontal.html +otree/templates/otree/forms/slider.html +otree/templates/otree/forms/layouts/bootstrap.html +otree/templates/otree/forms/rows/bootstrap.html +otree/templates/otree/forms/rows/bootstrap_checkbox.html +otree/templates/otree/includes/CreateSessionForm.html +otree/templates/otree/includes/OtreeDotOrgFeedbackWidget.html +otree/templates/otree/includes/RoomParticipantLinks.html +otree/templates/otree/includes/SessionInfo.html +otree/templates/otree/includes/TimeLimit.html +otree/templates/otree/includes/TimeLimit.js.html +otree/templates/otree/includes/debug_info.html +otree/templates/otree/includes/hidden_form_errors.html +otree/templates/otree/includes/messages.html +otree/templates/otree/tags/NextButton.html +otree/templates/otree/tags/_formfield.html +otree/templates/otreechat_core/widget.html +otree/templatetags/__init__.py +otree/templatetags/otree.py +otree/templatetags/otree_forms.py +otree/templatetags/otree_internal.py +otree/templatetags/otree_tags.py +otree/test/__init__.py +otree/timeout/__init__.py +otree/timeout/tasks.py +otree/views/__init__.py +otree/views/abstract.py +otree/views/admin.py +otree/views/demo.py +otree/views/export.py +otree/views/mturk.py +otree/views/participant.py +otree/views/room.py +otree_startup/__init__.py +otree_startup/asgi.py +otree_startup/settings.py \ No newline at end of file diff --git a/otree.egg-info/dependency_links.txt b/otree.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/otree.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/otree.egg-info/entry_points.txt b/otree.egg-info/entry_points.txt new file mode 100644 index 0000000..3fede33 --- /dev/null +++ b/otree.egg-info/entry_points.txt @@ -0,0 +1,3 @@ +[console_scripts] +otree = otree_startup:execute_from_command_line + diff --git a/otree.egg-info/not-zip-safe b/otree.egg-info/not-zip-safe new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/otree.egg-info/not-zip-safe @@ -0,0 +1 @@ + diff --git a/otree.egg-info/requires.txt b/otree.egg-info/requires.txt new file mode 100644 index 0000000..bafe959 --- /dev/null +++ b/otree.egg-info/requires.txt @@ -0,0 +1,55 @@ +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 +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 + +[mturk] +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==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/otree.egg-info/top_level.txt b/otree.egg-info/top_level.txt new file mode 100644 index 0000000..714cd15 --- /dev/null +++ b/otree.egg-info/top_level.txt @@ -0,0 +1,3 @@ +otree +otree_startup +tests diff --git a/otree/__init__.py b/otree/__init__.py new file mode 100644 index 0000000..f7044a9 --- /dev/null +++ b/otree/__init__.py @@ -0,0 +1,2 @@ +default_app_config = 'otree.apps.OtreeConfig' +__version__ = '2.1.1' \ No newline at end of file diff --git a/otree/api.py b/otree/api.py new file mode 100644 index 0000000..c38cca5 --- /dev/null +++ b/otree/api.py @@ -0,0 +1,10 @@ +from importlib import import_module as _import_module + +from otree.models import BaseSubsession, BaseGroup, BasePlayer # noqa +from otree.constants import BaseConstants # noqa +from otree.views import Page, WaitPage # noqa +from otree.common import Currency, currency_range, safe_json # noqa +from otree.bots import Bot, Submission, SubmissionMustFail # noqa + +models = _import_module('otree.models') +widgets = _import_module('otree.forms.widgets') diff --git a/otree/api.pyi b/otree/api.pyi new file mode 100644 index 0000000..f2b2a2b --- /dev/null +++ b/otree/api.pyi @@ -0,0 +1,294 @@ +from typing import Union, List, Any +from otree.common import RealWorldCurrency, Currency + +class Currency(Currency): + ''' + PyCharm autocomplete seems to require that I explicitly define the class in this file + (if I import, it says the reference to Currency is not found) + ''' + +def currency_range(first, last, increment) -> List[Currency]: pass +def safe_json(obj): pass + + +# mocking the public API for PyCharm autocomplete. +# one downside is that PyCharm doesn't seem to fully autocomplete arguments +# in the .pyi. It gives the yellow pop-up, but doesn't complete what you +# are typing. (2017-07-01: seems to work in PyCharm 2017.1.4?) +class models: + + ''' + The code in this class has nothing to do with implementation, + but rather defines the interface for model fields, + so that pyCharm autocompletes them properly. + + It defines their __init__ so that when instantiating the class, + PyCharm suggests the right arguments. + Apart from that, they can be used as the equivalent Python data type + (e.g. BooleanField is a bool, CharField is str) + + Without inheriting from bool, str, etc., PyCharm flags certain usages in yellow, + like: + + c(1) + c(1) + + Results in: "Currency does not define __add__, so the + operator cannot + be used on its instances" + + If "a" is a CurrencyField, then + self.a + c(1) + + PyCharm warns: 'Currency does not define __add__, so the + operator cannot + be used on its instances' + + c(1) + 1 + + 'Expected type "int", got "Currency" instead' + + self.a + 1 + + 'Expected type "int", got "CurrencyField" instead' + + + ''' + + def __getattr__(self, item): + pass + + class BooleanField(bool): + def __init__( + self, + *, + choices=None, + widget=None, + initial=None, + label=None, + doc='', + blank=False, + **kwargs): + pass + + class StringField(str): + def __init__( + self, + *, + choices=None, + widget=None, + initial=None, + label=None, + doc='', + max_length=10000, + blank=False, + **kwargs): + pass + + class LongStringField(str): + def __init__( + self, + *, + initial=None, + label=None, + doc='', + max_length=None, + blank=False, + **kwargs): + pass + + # need to copy-paste the __init__ between + # Integer, Float, and Currency + # because if I use inheritance, PyCharm doesn't auto-complete + # while typing args + + class IntegerField(int): + def __init__( + self, + *, + choices=None, + widget=None, + initial=None, + label=None, + doc='', + min=None, + max=None, + blank=False, + **kwargs): + pass + + class FloatField(float): + def __init__( + self, + *, + choices=None, + widget=None, + initial=None, + label=None, + doc='', + min=None, + max=None, + blank=False, + **kwargs): + pass + + class CurrencyField(Currency): + def __init__( + self, + *, + choices=None, + widget=None, + initial=None, + label=None, + doc='', + min=None, + max=None, + blank=False, + **kwargs): + pass + + + +class widgets: + def __getattr__(self, item): + pass + + # don't need HiddenInput because you can just write + # and then you know the element's selector + class CheckboxInput: pass + class Select: pass + class RadioSelect: pass + class RadioSelectHorizontal: pass + class Slider: pass + # useful if you use choices= but don't want a dropdown + # (e.g. don't want bias) + class TextInput: pass + + +class Session: + + config = None # type: dict + vars = None # type: dict + num_participants = None # type: int + def get_participants(self) -> List[Participant]: pass + def get_subsessions(self) -> List[BaseSubsession]: pass + +class Participant: + + session = None # type: Session + vars = None # type: dict + label = None # type: str + id_in_session = None # type: int + payoff = None # type: Currency + + def get_players(self) -> List[BasePlayer]: pass + def payoff_plus_participation_fee(self) -> RealWorldCurrency: pass + + +class BaseConstants: pass + + +class BaseSubsession: + + session = None # type: Session + round_number = None # type: int + + def get_groups(self) -> List[BaseGroup]: pass + def get_group_matrix(self) -> List[List[BasePlayer]]: pass + def set_group_matrix( + self, + group_matrix: Union[List[List[BasePlayer]],List[List[int]]]): pass + def get_players(self) -> List[BasePlayer]: pass + def in_previous_rounds(self) -> List['BaseSubsession']: pass + def in_all_rounds(self) -> List['BaseSubsession']: pass + def creating_session(self): pass + def in_round(self, round_number) -> 'BaseSubsession': pass + def in_rounds(self, first, last) -> List['BaseSubsession']: pass + def group_like_round(self, round_number: int): pass + def group_randomly(self, fixed_id_in_group: bool=False): pass + def vars_for_admin_report(self): pass + + # this is so PyCharm doesn't flag attributes that are only defined on the app's Subsession, + # not on the BaseSubsession + def __getattribute__(self, item): pass + +class BaseGroup: + + session = None # type: Session + subsession = None # type: BaseSubsession + round_number = None # type: int + + def set_players(self, players_list: List[BasePlayer]): pass + def get_players(self) -> List[BasePlayer]: pass + def get_player_by_role(self, role) -> BasePlayer: pass + def get_player_by_id(self, id_in_group) -> BasePlayer: pass + def in_previous_rounds(self) -> List['BaseGroup']: pass + def in_all_rounds(self) -> List['BaseGroup']: pass + def in_round(self, round_number) -> 'BaseGroup': pass + def in_rounds(self, first: int, last: int) -> List['BaseGroup']: pass + + def __getattribute__(self, item): pass + +class BasePlayer: + + id_in_group = None # type: int + payoff = None # type: Currency + participant = None # type: Participant + session = None # type: Session + group = None # type: BaseGroup + subsession = None # type: BaseSubsession + round_number = None # type: int + + def in_previous_rounds(self) -> List['BasePlayer']: pass + def in_all_rounds(self) -> List['BasePlayer']: pass + def get_others_in_group(self) -> List['BasePlayer']: pass + def get_others_in_subsession(self) -> List['BasePlayer']: pass + def role(self) -> str: pass + def in_round(self, round_number) -> 'BasePlayer': pass + def in_rounds(self, first, last) -> List['BasePlayer']: pass + + def __getattribute__(self, item): pass + + +class WaitPage: + wait_for_all_groups = False + group_by_arrival_time = False + title_text = None + body_text = None + template_name = None + round_number = None # type: int + participant = None # type: Participant + session = None # type: Session + + def is_displayed(self) -> bool: pass + def after_all_players_arrive(self): pass + def get_players_for_group(self, waiting_players): pass + + +class Page: + round_number = None # type: int + template_name = None # type: str + timeout_seconds = None # type: int + timeout_submission = None # type: dict + timeout_happened = None # type: bool + timer_text = None # type: str + participant = None # type: Participant + session = None # type: Session + form_model = None # + form_fields = None # type: List[str] + + def get_form_fields(self) -> List['str']: pass + def vars_for_template(self) -> dict: pass + def before_next_page(self): pass + def is_displayed(self) -> bool: pass + def error_message(self, values): pass + def get_timeout_seconds(self): pass + + +class Bot: + html = '' # type: str + case = None # type: Any + cases = [] # type: List + participant = None # type: Participant + session = None # type: Participant + round_number = None # type: int + +def Submission(PageClass, post_data: dict={}, *, check_html=True, timeout_happened=False): pass +def SubmissionMustFail(PageClass, post_data: dict={}, *, check_html=True, error_fields=[]): pass diff --git a/otree/app_template/__init__.py b/otree/app_template/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/otree/app_template/_builtin/__init__.py b/otree/app_template/_builtin/__init__.py new file mode 100644 index 0000000..0aea98e --- /dev/null +++ b/otree/app_template/_builtin/__init__.py @@ -0,0 +1,25 @@ +# This file is auto-generated. +# It's used to aid autocompletion in code editors. + +import otree.api +from .. import models + + +class Page(otree.api.Page): + def z_autocomplete(self): + self.subsession = models.Subsession() + self.group = models.Group() + self.player = models.Player() + + +class WaitPage(otree.api.WaitPage): + def z_autocomplete(self): + self.subsession = models.Subsession() + self.group = models.Group() + + +class Bot(otree.api.Bot): + def z_autocomplete(self): + self.subsession = models.Subsession() + self.group = models.Group() + self.player = models.Player() diff --git a/otree/app_template/models.py b/otree/app_template/models.py new file mode 100644 index 0000000..3ec45e6 --- /dev/null +++ b/otree/app_template/models.py @@ -0,0 +1,29 @@ +from otree.api import ( + models, widgets, BaseConstants, BaseSubsession, BaseGroup, BasePlayer, + Currency as c, currency_range +) + + +author = 'Your name here' + +doc = """ +Your app description +""" + + +class Constants(BaseConstants): + name_in_url = '{{ app_name }}' + players_per_group = None + num_rounds = 1 + + +class Subsession(BaseSubsession): + pass + + +class Group(BaseGroup): + pass + + +class Player(BasePlayer): + pass diff --git a/otree/app_template/pages.py b/otree/app_template/pages.py new file mode 100644 index 0000000..5e7439a --- /dev/null +++ b/otree/app_template/pages.py @@ -0,0 +1,24 @@ +from otree.api import Currency as c, currency_range +from ._builtin import Page, WaitPage +from .models import Constants + + +class MyPage(Page): + pass + + +class ResultsWaitPage(WaitPage): + + def after_all_players_arrive(self): + pass + + +class Results(Page): + pass + + +page_sequence = [ + MyPage, + ResultsWaitPage, + Results +] diff --git a/otree/app_template/templates/app_name/MyPage.html b/otree/app_template/templates/app_name/MyPage.html new file mode 100644 index 0000000..fd5efc3 --- /dev/null +++ b/otree/app_template/templates/app_name/MyPage.html @@ -0,0 +1,12 @@ +{% extends "global/Page.html" %} +{% load otree static %} + +{% block title %} + Page title +{% endblock %} + +{% block content %} + + {% next_button %} + +{% endblock %} diff --git a/otree/app_template/templates/app_name/Results.html b/otree/app_template/templates/app_name/Results.html new file mode 100644 index 0000000..3719f3e --- /dev/null +++ b/otree/app_template/templates/app_name/Results.html @@ -0,0 +1,13 @@ +{% extends "global/Page.html" %} +{% load otree static %} + +{% block title %} + Page title +{% endblock %} + +{% block content %} + + {% next_button %} +{% endblock %} + + diff --git a/otree/app_template/tests.py b/otree/app_template/tests.py new file mode 100644 index 0000000..ea0862b --- /dev/null +++ b/otree/app_template/tests.py @@ -0,0 +1,11 @@ +from otree.api import Currency as c, currency_range +from . import pages +from ._builtin import Bot +from .models import Constants + + +class PlayerBot(Bot): + + def play_round(self): + yield (pages.MyPage) + yield (pages.Results) diff --git a/otree/apps.py b/otree/apps.py new file mode 100644 index 0000000..20b0103 --- /dev/null +++ b/otree/apps.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import logging +import sys + +import colorama +from django.apps import AppConfig +from django.conf import settings +from django.db.models import signals + +import otree +import otree.common_internal +from otree.common_internal import ( + ensure_superuser_exists +) +from otree.strict_templates import patch_template_silent_failures + +logger = logging.getLogger('otree') + + +def create_singleton_objects(sender, **kwargs): + from otree.models_concrete import UndefinedFormModel + for ModelClass in [UndefinedFormModel]: + # if it doesn't already exist, create one. + ModelClass.objects.get_or_create() + + +def monkey_patch_static_tag(): + ''' + In Django >= 1.10, you can use {% load static %} + instead of {% load staticfiles %}. if we switch to that format, + then it will bypass this. so eventually after Django 1.10, we + should change this code to patch django.templatetags.static.static + ''' + + from django.contrib.staticfiles.storage import staticfiles_storage + from django.contrib.staticfiles.templatetags import staticfiles + + def patched_static(path): + '''same 1-line function, + just tries to give a friendlier error message''' + try: + return staticfiles_storage.url(path) + except ValueError as exc: + # Heroku and "otree runprodserver" both execute collectstatic + # automatically, so there is ordinarily no need to suggest + # running collectstatic if a file is not found. It's more likely + # that the file doesn't exist or wasn't added in git. + if 'runserver' in sys.argv or 'devserver' in sys.argv: + msg = '{} - did you remember to run "otree collectstatic"?' + raise ValueError(msg.format(exc)) from None + else: + raise exc from None + + + staticfiles.static = patched_static + + +SQLITE_LOCKING_ADVICE = ( + 'Locking is common with SQLite. ' + 'When you run your study, you should use a database like PostgreSQL ' + 'that is resistant to locking' +) + + +def monkey_patch_db_cursor(): + '''Monkey-patch the DB cursor, to catch ProgrammingError and + OperationalError. The alternative is to use middleware, but (1) + that doesn't catch errors raised outside of views, like channels consumers + and the task queue, and (2) it's not as specific, because there are + OperationalErrors that come from different parts of the app that are + unrelated to resetdb. This is the most targeted location. + ''' + + + # In Django 2.0, this method is renamed to _execute. + def execute(self, sql, params=None): + self.db.validate_no_broken_transaction() + with self.db.wrap_database_errors: + if params is None: + return self.cursor.execute(sql) + else: + try: + return self.cursor.execute(sql, params) + except Exception as exc: + ExceptionClass = type(exc) + # it seems there are different exceptions all named + # OperationalError (django.db.OperationalError, + # sqlite.OperationalError, mysql....) + # so, simplest to use the string name + if ExceptionClass.__name__ in ( + 'OperationalError', 'ProgrammingError'): + # these error messages are localized, so we can't + # just check for substring 'column' or 'table' + # all the ProgrammingError and OperationalError + # instances I've seen so far are related to resetdb, + # except for "database is locked" + tb = sys.exc_info()[2] + + if 'locked' in str(exc): + advice = SQLITE_LOCKING_ADVICE + else: + advice = 'try running "otree resetdb"' + + raise ExceptionClass('{} - {}.'.format( + exc, advice)).with_traceback(tb) from None + else: + raise + + from django.db.backends import utils + utils.CursorWrapper.execute = execute + + +def setup_create_default_superuser(): + signals.post_migrate.connect( + ensure_superuser_exists, + dispatch_uid='otree.create_superuser' + ) + + +def setup_create_singleton_objects(): + signals.post_migrate.connect(create_singleton_objects, + dispatch_uid='create_singletons') + + +def patch_raven_config(): + # patch settings with info that is only available + # after other settings loaded + if hasattr(settings, 'RAVEN_CONFIG'): + settings.RAVEN_CONFIG['release'] = '{}{}'.format( + otree.__version__, + # need to pass the server if it's DEBUG + # mode. could do this in extra context or tags, + # but this seems the most straightforward way + ',dbg' if settings.DEBUG else '' + ) + + +class OtreeConfig(AppConfig): + name = 'otree' + label = 'otree' + verbose_name = "oTree" + + def ready(self): + setup_create_singleton_objects() + setup_create_default_superuser() + patch_raven_config() + monkey_patch_static_tag() + monkey_patch_db_cursor() + # to initialize locks + + colorama.init(autoreset=True) + + import otree.checks + otree.checks.register_system_checks() + patch_template_silent_failures() + + diff --git a/otree/asgi.py b/otree/asgi.py new file mode 100644 index 0000000..0c0aa40 --- /dev/null +++ b/otree/asgi.py @@ -0,0 +1 @@ +from otree_startup.asgi import channel_layer \ No newline at end of file diff --git a/otree/bots/__init__.py b/otree/bots/__init__.py new file mode 100644 index 0000000..0d08d08 --- /dev/null +++ b/otree/bots/__init__.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# 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. + +# 2016-07-18: not using the import_module trick for now, because currently, +# the PlayerBot class doesn't have any methods we need to hide +# from importlib import import_module +# otree_bot = import_module('otree.bots.bot') + +from importlib import import_module + +_bot_module = import_module('otree.bots.bot') + +Bot = _bot_module.PlayerBot +Submission = _bot_module.Submission +SubmissionMustFail = _bot_module.SubmissionMustFail diff --git a/otree/bots/bot.py b/otree/bots/bot.py new file mode 100644 index 0000000..ad2d3d3 --- /dev/null +++ b/otree/bots/bot.py @@ -0,0 +1,462 @@ +from typing import List +import re +import decimal +import logging +import abc +import six +from six.moves import urllib +from six.moves.html_parser import HTMLParser + +from otree.models_concrete import ParticipantToPlayerLookup +from django import test +from django.core.urlresolvers import resolve +from django.conf import settings +from otree.currency import Currency +from django.apps import apps +from otree import constants_internal +from otree.models import Participant, Session +from otree import common_internal +from otree.common_internal import ( + get_dotted_name, get_bots_module, get_admin_secret_code, + get_models_module +) + +ADMIN_SECRET_CODE = get_admin_secret_code() + +logger = logging.getLogger('otree.bots') + +INTERNAL_FORM_FIELDS = { + 'csrfmiddlewaretoken', 'must_fail', 'timeout_happened', + 'admin_secret_code', 'error_fields' +} + +DISABLE_CHECK_HTML_INSTRUCTIONS = ''' +Checking the HTML may not find all form fields and buttons +(e.g. those added with JavaScript), +so you can disable this check by yielding a Submission +with check_html=False, e.g.: + +yield Submission(views.PageName, {{...}}, check_html=False) +''' + +HTML_MISSING_BUTTON_WARNING = (''' +Bot is trying to submit page {page_name}, +but no button was found in the HTML of the page. +(searched for with type='submit' or + + + + +
+ + {% 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 %} +
{% csrf_token %} + +
{% block content %}{% endblock %}
+
+
+ {% 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 %} +

+
+
+ {% if invalid_label %} +

Invalid label; try again.

+ {% endif %} + +
+ +
+ {% next_button %} +
+ +
+
+{% 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 %} + +
+
+

+ {% block title %}{{ title_text }}{% endblock %} +

+
+ + + {% 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 }} +
+ + + + + + +
{% formfield form.app_name %}{% formfield form.round_number %} + +
+
+ + {% 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 %} + +
+

+ Citation requirement +

+
+

+ 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. +

+ + + + + + + + + + + {% for app in app_names %} + + + + + + + {% endfor %} + +
AppDataDocumentation
+ {{ app }} + + Excel + | + CSV + + TXT + +
+

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: +

+ + + + + + + + + + + + + + + + + + + +
StepDone?
+ 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. +
    +
  1. + If using Heroku, you can simply change the URL + in your browser's address bar to start with 'https://' + and reload this page. +
  2. + +
+
{% 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 %} + +
{% csrf_token %} + + + {% if not session.mturk_HITId %} + {% include 'otree/forms/layouts/bootstrap.html' %} + {% if missing_next_button_warning %} +
+ {{ missing_next_button_warning }} +
+ {% endif %} + + + {% else %} +

+ You have published HIT for this session on MTurk + {% if session.mturk_use_sandbox %} + Sandbox + {% endif %} + .

+

+ To look at the HIT as requester + follow this link.
+ To look at the HIT as a worker + follow this link. +

+ {% 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 %} +
+ {% csrf_token %} + +

Assignments to be reviewed

+ + + + + + + + + + + + + + {% for p in participants_not_reviewed %} + + + + + + + + + + + {% endfor %} +
Participant codeAssignment IdWorker IdProgress + Participation fee (Reward) + + Variable pay (Bonus) + Total pay + Select +
+ +
+
{{ p.code }}{{ p.mturk_assignment_id|default_if_none:"" }}{{ p.mturk_worker_id|default_if_none:"" }}{{ p.current_page_ }} + {{ participation_fee }} + + {{ p.payoff_in_real_world_currency }} + {{ p.payoff_plus_participation_fee }} +
+ +
+
+
+ + +
+ + + + + +
+ + + {% endif %} + + + {% if participants_approved %} +

Approved assignments

+ + + + + + + + + + + {% for p in participants_approved %} + + + + + + + + + {% endfor %} +
Participant codeAssignment IdWorker Id + Participation fee (Reward) + + Variable pay (Bonus) + Total pay
{{ 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 }}
+ {% endif %} + {% if participants_rejected %} +

Rejected assignments

+ + + + + + + + + + + {% for p in participants_rejected %} + + + + + + + + + {% endfor %} +
Participant codeAssignment IdWorker Id + Participation fee (Reward) + + Variable pay (Bonus) + Total pay
{{ 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 }}
+ {% 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 %} + +
+ Go to active session. +
+ +
+ {% csrf_token %} + + + +
+ + {% 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 %} + + {% 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 %} +

+ {{ room.display_name }} +

+{% 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:

+ + + {% 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 }} + + + + + {% for header, colspan in subsession_headers %} + + {% endfor %} + + + {% for header, colspan in model_headers %} + + {% endfor %} + + + {% for header in field_headers %} + + {% endfor %} + + +
ID in session{{ header }}
{{ header }}
{{ header }}
+ +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" %} +
+ {% csrf_token %} + {% include "otree/forms/layouts/bootstrap.html" %} + {% next_button %} +
+ +{% 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 %} + + {% endfor %} + + +
{{ header }}
+ +

/{{ session.num_participants }} participants started.

+
+ {% if not session.use_browser_bots %} + + {% endif %} + + +
+ + + + + {% 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

+ + + + + + + + + + + + + + {% for p in participants %} + + + + + + + + + + {% endfor %} + +
Participant codeParticipant labelProgressParticipation feePayoff (bonus)TotalNote
{{ p.code }}{{ p.label|default_if_none:"" }}{{ p.current_page_ }}{{ participation_fee }}{{ p.payoff_in_real_world_currency }}{{ p.payoff_plus_participation_fee }}
+ +

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 %} + + {% 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 %} + + + + + {% endfor %} +
P{{ forloop.counter }}{{ participant_url }}
+
+ + {% 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 %} + + + + + {% endfor %} +
P{{ forloop.counter }}{{ participant_url }}
+ +{% 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 %} + + + + + {% endfor %} +
P{{ forloop.counter }}{{ participant_url }}
+ + +{% 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 %} +
+ {% csrf_token %} + + + +
+ + +
+ +

+ + + + + + + + + + + {% for s in object_list %} + + + + + + + + {% endfor %} + +
+ + CodeLabelConfig
+ + {{ s.code }}{{ s.label }}{{ s.config.name }} + + Links + + + Edit + + + Monitor + + + Data + + + Payments + + {% if s.is_for_mturk %} + {% if not s.mturk_HITId %} + + MTurk    + + {% else %} +
+ + +
+ {% endif %} + {% endif %} +
+
+ {% endif %} + {% if not is_archive and archived_sessions_exist %} +
+
+ Archived sessions +
+
+ {% 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 %} +
+ × + {{ error }} +
+{% 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 @@ +
+ {% include "django/forms/widgets/input.html" %} +
+ {{ currency_symbol }} +
+
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 %} +
+ {% with classes=field.css_classes %} + {% if field.is_hidden %} + {{ field.as_hidden }} + {% else %} + {% if label %}{% if field|id %}{% endif %}{% endif %} +
+ {{ field }} + {% include "otree/forms/errors.html" with errors=field.errors %} + {% if field.help_text %} + +

{{ field.help_text }}

+
+ {% endif %} +
+ {% endif %} + {% endwith %} +
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 %} +
+ {% with classes=field.css_classes %} + + {% include "otree/forms/errors.html" with errors=field.errors %} + {% if field.help_text %} +

{{ field.help_text }}

+ {% endif %} + {% for field in hidden_fields %}{{ field.as_hidden }}{% endfor %} + {% endwith %} +
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 %} +
+ {% include "django/forms/widgets/input.html" %} +
+ +
+
+{% 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 %} + +
+ {% csrf_token %} + + {% formfield form.session_config %} + +
+ +
+ {{ form.num_participants }} + {% if form.num_participants.errors %} + {% include "otree/forms/errors.html" with errors=form.num_participants.errors %} + {% endif %} + {% for config in configs %} + + {% endfor %} +

{{ form.num_participants.help_text }}

+
+
+ + + +

+ {% for config in configs %} + + + + + {% include "otree/includes/SessionInfo.html" with config=config config_display="none" %} + + {% endfor %} +
+ + 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 %} + + + + +{% 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 %} + + + + +{% endfor %} +
{{ app.name }} +

+ {{ app.doc|safe }} +

+
+ +
\ 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 #} +
Debug info
+ + {% include 'otree/includes/hidden_form_errors.html' %} + +{% for table in view.debug_tables|default:None %} +
+
{{ table.title }}
+
+ + {% for k, v in table.rows %} + + + + + {% endfor %} +
{{ k }} + {{ v }} +
+
+
+{% 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 %} + + {% 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 %} +
+ × + {% if 'safe' in message.tags %}{{ message|safe }}{% else %}{{ message }}{% endif %} +
+{% 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 %}

+ +
+
{% csrf_token %} +
+ {% formfield form.username %} +
+
+ {% formfield form.password %} + +
+

+ The password is defined in your settings.py + file. +

+ + {% url 'admin_password_reset' as password_reset_url %} + {% if password_reset_url %} + + {% endif %} +
+ +
+
+ + +
+{% 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 + } +)