├── otree ├── auth.py ├── cli │ ├── __init__.py │ ├── prodserver.py │ ├── prodserver2of2.py │ ├── timeoutsubprocess.py │ ├── base.py │ ├── browser_bots.py │ ├── unzip.py │ ├── create_session.py │ ├── prodserver1of2.py │ ├── bots.py │ ├── startapp.py │ ├── resetdb.py │ ├── devserver_inner.py │ ├── upcase_constants.py │ ├── startproject.py │ └── devserver.py ├── forms │ ├── __init__.py │ └── fields.py ├── channels │ ├── __init__.py │ └── routing.py ├── app_template │ ├── __init__.py │ ├── tests.py │ ├── templates │ │ └── app_name │ │ │ ├── Results.html │ │ │ └── MyPage.html │ ├── pages.py │ ├── _builtin │ │ └── __init__.py │ └── models.py ├── project_template │ ├── _static │ │ └── global │ │ │ └── empty.css │ ├── Procfile │ ├── .gitignore │ ├── _templates │ │ └── global │ │ │ └── Page.html │ ├── requirements.txt │ └── settings.py ├── static │ ├── robots.txt │ ├── favicon.ico │ ├── glyphicons │ │ ├── usd.png │ │ ├── clock.png │ │ ├── cloud.png │ │ ├── link.png │ │ ├── plus.png │ │ ├── stats.png │ │ ├── cogwheel.png │ │ ├── delete.png │ │ ├── eye-open.png │ │ ├── list-alt.png │ │ ├── pencil.png │ │ ├── pushpin.png │ │ ├── refresh.png │ │ ├── download-alt.png │ │ └── folder-closed.png │ └── otree │ │ ├── js │ │ ├── internet-explorer.js │ │ ├── live.js │ │ ├── jquery.timeago.en-short.js │ │ ├── page-websocket-redirect.js │ │ ├── formInputs.js │ │ └── common.js │ │ └── css │ │ ├── table.css │ │ └── theme.css ├── templates │ ├── global │ │ ├── Base.html │ │ └── Page.html │ └── otree │ │ ├── FormPage.html │ │ ├── OutOfRangeNotification.html │ │ ├── WaitPageRoom.html │ │ ├── SessionDescription.html │ │ ├── CreateSession.html │ │ ├── includes │ │ ├── TimeLimit.html │ │ ├── messages.html │ │ ├── SessionInfo.html │ │ ├── debug_info.html │ │ ├── TimeLimit.js.html │ │ ├── mturk_payment_table.html │ │ ├── RoomParticipantLinks.html │ │ └── hidden_form_errors.html │ │ ├── SessionEditProperties.html │ │ ├── Rooms.html │ │ ├── RoomWithSession.html │ │ ├── RoomInputLabel.html │ │ ├── AdminReport.html │ │ ├── DemoIndex.html │ │ ├── MTurkHTMLQuestion.html │ │ ├── Login.html │ │ ├── Base.html │ │ ├── SessionPayments.html │ │ ├── ServerCheck.html │ │ ├── Page.html │ │ ├── SessionMonitor.html │ │ ├── Session.html │ │ ├── MTurkCreateHIT.html │ │ ├── CreateDemoSession.html │ │ ├── BaseAdmin.html │ │ ├── SessionSplitScreen.html │ │ └── tags │ │ └── chat.html ├── views │ ├── __init__.py │ ├── demo.py │ └── room.py ├── bots │ └── __init__.py ├── templating │ ├── __init__.py │ ├── errors.py │ ├── template.py │ ├── utils.py │ ├── filters.py │ ├── context.py │ └── loader.py ├── __init__.py ├── locale │ ├── ar │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── cs │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── de │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── es │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── fr │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── he │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── hi │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── hu │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── id │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── it │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── ja │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── ko │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── nb │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── nl │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── pl │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── pt │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── ru │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── tr │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── zh_Hans │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── babel.ini │ └── django.pot ├── app_template_lite │ ├── Results.html │ ├── MyPage.html │ └── __init__.py ├── test.py ├── models │ ├── __init__.py │ └── player.py ├── api.py ├── middleware.py ├── patch.py ├── update.py ├── models_concrete.py ├── lookup.py ├── chat.py ├── settings.py ├── constants.py ├── read_csv.py ├── asgi.py └── live.py ├── Makefile ├── .gitignore ├── requirements.txt ├── MANIFEST.in ├── LICENSE ├── README.rst └── setup.py /otree/auth.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /otree/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /otree/forms/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /otree/channels/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /otree/app_template/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /otree/project_template/_static/global/empty.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /otree/cli/prodserver.py: -------------------------------------------------------------------------------- 1 | from .prodserver1of2 import Command -------------------------------------------------------------------------------- /otree/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /otree/templates/global/Base.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/Page.html" %} 2 | -------------------------------------------------------------------------------- /otree/templates/otree/FormPage.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/Page.html" %} -------------------------------------------------------------------------------- /otree/templates/otree/OutOfRangeNotification.html: -------------------------------------------------------------------------------- 1 | No more pages left -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | DJANGO_SETTINGS_MODULE=tests.settings py.test tests/ 3 | -------------------------------------------------------------------------------- /otree/views/__init__.py: -------------------------------------------------------------------------------- 1 | from otree.views.abstract import WaitPage, Page 2 | -------------------------------------------------------------------------------- /otree/templates/otree/WaitPageRoom.html: -------------------------------------------------------------------------------- 1 | {% extends 'otree/WaitPage.html' %} 2 | -------------------------------------------------------------------------------- /otree/project_template/Procfile: -------------------------------------------------------------------------------- 1 | web: otree prodserver1of2 2 | worker: otree prodserver2of2 3 | -------------------------------------------------------------------------------- /otree/bots/__init__.py: -------------------------------------------------------------------------------- 1 | from .bot import PlayerBot as Bot, Submission, SubmissionMustFail, expect 2 | -------------------------------------------------------------------------------- /otree/templating/__init__.py: -------------------------------------------------------------------------------- 1 | from .loader import ibis_loader, get_template_name_if_exists, render 2 | -------------------------------------------------------------------------------- /otree/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/static/favicon.ico -------------------------------------------------------------------------------- /otree/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '5.11.3' 2 | # don't import anything else here because setup.py imports this. 3 | -------------------------------------------------------------------------------- /otree/static/glyphicons/usd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/static/glyphicons/usd.png -------------------------------------------------------------------------------- /otree/static/glyphicons/clock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/static/glyphicons/clock.png -------------------------------------------------------------------------------- /otree/static/glyphicons/cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/static/glyphicons/cloud.png -------------------------------------------------------------------------------- /otree/static/glyphicons/link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/static/glyphicons/link.png -------------------------------------------------------------------------------- /otree/static/glyphicons/plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/static/glyphicons/plus.png -------------------------------------------------------------------------------- /otree/static/glyphicons/stats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/static/glyphicons/stats.png -------------------------------------------------------------------------------- /otree/static/glyphicons/cogwheel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/static/glyphicons/cogwheel.png -------------------------------------------------------------------------------- /otree/static/glyphicons/delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/static/glyphicons/delete.png -------------------------------------------------------------------------------- /otree/static/glyphicons/eye-open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/static/glyphicons/eye-open.png -------------------------------------------------------------------------------- /otree/static/glyphicons/list-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/static/glyphicons/list-alt.png -------------------------------------------------------------------------------- /otree/static/glyphicons/pencil.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/static/glyphicons/pencil.png -------------------------------------------------------------------------------- /otree/static/glyphicons/pushpin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/static/glyphicons/pushpin.png -------------------------------------------------------------------------------- /otree/static/glyphicons/refresh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/static/glyphicons/refresh.png -------------------------------------------------------------------------------- /otree/locale/ar/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/ar/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/cs/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/cs/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/he/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/he/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/hi/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/hi/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/hu/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/hu/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/id/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/id/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/it/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/it/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/ja/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/ja/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/ko/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/ko/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/nb/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/nb/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/nl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/nl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/pl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/pl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/pt/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/pt/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/ru/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/tr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/tr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/static/glyphicons/download-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/static/glyphicons/download-alt.png -------------------------------------------------------------------------------- /otree/static/glyphicons/folder-closed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/static/glyphicons/folder-closed.png -------------------------------------------------------------------------------- /otree/locale/zh_Hans/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oTree-org/otree-core/HEAD/otree/locale/zh_Hans/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /otree/locale/babel.ini: -------------------------------------------------------------------------------- 1 | [extractors] 2 | otreetemplate = otree.i18n:extract_otreetemplate_internal 3 | 4 | 5 | [otreetemplate: **.html] 6 | [python: **.py] 7 | -------------------------------------------------------------------------------- /otree/app_template_lite/Results.html: -------------------------------------------------------------------------------- 1 | {{ block title }} 2 | Page title 3 | {{ endblock }} 4 | 5 | {{ block content }} 6 | 7 | {{ next_button }} 8 | {{ endblock }} 9 | 10 | 11 | -------------------------------------------------------------------------------- /otree/test.py: -------------------------------------------------------------------------------- 1 | # for compat with apps written in older versions 2 | # unfortunately this is still hardcoded in some widespread apps' _builtin/__init__.py 3 | from otree.bots import Bot # noqa 4 | -------------------------------------------------------------------------------- /otree/templates/otree/SessionDescription.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/Session.html" %} 2 | 3 | {% block content %} 4 | {{ super() }} 5 | {% include 'otree/includes/SessionInfo.html' %} 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /otree/app_template_lite/MyPage.html: -------------------------------------------------------------------------------- 1 | {{ block title }} 2 | Page title 3 | {{ endblock }} 4 | {{ block content }} 5 | 6 | {{ formfields }} 7 | {{ next_button }} 8 | 9 | {{ endblock }} 10 | -------------------------------------------------------------------------------- /otree/project_template/.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | staticfiles 3 | ./db.sqlite3 4 | .idea 5 | *~ 6 | *.sqlite3 7 | _static_root 8 | _bots*s 9 | __temp* 10 | __pycache__/ 11 | *.py[cod] 12 | .DS_Store 13 | *.otreezip -------------------------------------------------------------------------------- /otree/project_template/_templates/global/Page.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/Page.html" %} 2 | {% load otree static %} 3 | 4 | {% block global_styles %} 5 | {% endblock %} 6 | 7 | {% block global_scripts %} 8 | {% endblock %} -------------------------------------------------------------------------------- /otree/project_template/requirements.txt: -------------------------------------------------------------------------------- 1 | # oTree-may-overwrite-this-file 2 | # IF YOU MODIFY THIS FILE, remove this comment. 3 | # otherwise, oTree will automatically overwrite it. 4 | otree>=5.0.0a21 5 | psycopg2>=2.8.4 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | database.db 3 | dist/ 4 | docs/_build/doctrees 5 | docs/_build/html/_sources/ 6 | docs/app/Thumbs.db 7 | myapp* 8 | .idea/ 9 | *.pyc 10 | db.sqlite3 11 | .tox/ 12 | .coverage 13 | htmlcov/ 14 | tests/_static_root 15 | __temp_* 16 | -------------------------------------------------------------------------------- /otree/app_template/tests.py: -------------------------------------------------------------------------------- 1 | from otree.api import Currency as c, currency_range 2 | from . import pages 3 | from ._builtin import Bot 4 | from .models import Constants 5 | 6 | 7 | class PlayerBot(Bot): 8 | def play_round(self): 9 | pass 10 | -------------------------------------------------------------------------------- /otree/models/__init__.py: -------------------------------------------------------------------------------- 1 | from otree.models.subsession import BaseSubsession 2 | from otree.models.group import BaseGroup 3 | from otree.models.player import BasePlayer 4 | from otree.models.session import Session 5 | from otree.models.participant import Participant 6 | -------------------------------------------------------------------------------- /otree/app_template/templates/app_name/Results.html: -------------------------------------------------------------------------------- 1 | {% extends "global/Page.html" %} 2 | {% load otree static %} 3 | 4 | {% block title %} 5 | Page title 6 | {% endblock %} 7 | 8 | {% block content %} 9 | 10 | {% next_button %} 11 | {% endblock %} 12 | 13 | 14 | -------------------------------------------------------------------------------- /otree/templates/otree/CreateSession.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/BaseAdmin.html" %} 2 | 3 | {% block title %} 4 | Create a new session 5 | {% endblock %} 6 | 7 | {% block content %} 8 | 9 | {% include "otree/includes/CreateSessionForm.html" %} 10 | 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /otree/app_template/templates/app_name/MyPage.html: -------------------------------------------------------------------------------- 1 | {% extends "global/Page.html" %} 2 | {% load otree static %} 3 | 4 | {% block title %} 5 | Page title 6 | {% endblock %} 7 | 8 | {% block content %} 9 | 10 | {% formfields %} 11 | {% next_button %} 12 | 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /otree/templates/otree/includes/TimeLimit.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |

{{ timer_text }} 6 | 7 | 8 | 9 |

10 |
11 | -------------------------------------------------------------------------------- /otree/templates/otree/includes/messages.html: -------------------------------------------------------------------------------- 1 | {% for ele in admin_message_queue %} 2 |
3 | {{ ele.msg }} 4 |
5 | {% endfor %} 6 | {# .clear() returns None so we convert that to empty string #} 7 | {{ admin_message_queue.clear() || "" }} 8 | -------------------------------------------------------------------------------- /otree/cli/prodserver2of2.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from .base import BaseCommand 3 | 4 | 5 | class Command(BaseCommand): 6 | '''legacy: doesn't do anything since we moved timeoutworker into main dyno''' 7 | 8 | def handle(self, *args, **options): 9 | while True: 10 | sleep(10) 11 | -------------------------------------------------------------------------------- /otree/templates/global/Page.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/Page.html" %} 2 | 3 | {% comment %} 4 | Since we might remove _templates/ from the project template, 5 | this is a stopgap in case a user tries to do % extends "global/Page.html" % 6 | 7 | If the user has a Page.html, however, it must take precedence over this 8 | template. 9 | {% endcomment %} 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiofiles==0.6.0 # starlette StaticFiles 2 | itsdangerous==1.1.0 3 | markupsafe==1.1.1 4 | python-multipart==0.0.5 5 | sqlalchemy==1.3.22 6 | starlette==0.14.1 7 | uvicorn==0.13.4 8 | websockets==10.1 # this library is optional but auto-pings websockets and detects disconnects (e.g. rooms) 9 | wtforms==2.3.3 10 | WTForms-SQLAlchemy==0.2 -------------------------------------------------------------------------------- /otree/app_template/pages.py: -------------------------------------------------------------------------------- 1 | from otree.api import Currency as c, currency_range 2 | from ._builtin import Page, WaitPage 3 | from .models import Constants 4 | 5 | 6 | class MyPage(Page): 7 | pass 8 | 9 | 10 | class ResultsWaitPage(WaitPage): 11 | pass 12 | 13 | 14 | class Results(Page): 15 | pass 16 | 17 | 18 | page_sequence = [MyPage, ResultsWaitPage, Results] 19 | -------------------------------------------------------------------------------- /otree/cli/timeoutsubprocess.py: -------------------------------------------------------------------------------- 1 | # run the worker to enforce page timeouts 2 | # even if the user closes their browser 3 | from .base import BaseCommand 4 | from otree.tasks import Worker 5 | 6 | 7 | class Command(BaseCommand): 8 | def add_arguments(self, parser): 9 | parser.add_argument('port', type=int) 10 | 11 | def handle(self, *args, port, **options): 12 | Worker(port).listen() 13 | -------------------------------------------------------------------------------- /otree/templates/otree/SessionEditProperties.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/Session.html" %} 2 | 3 | 4 | {% block content %} 5 | {{ super() }} 6 | {% include "otree/includes/messages.html" %} 7 |
8 | {% csrf_token %} 9 | 10 | {% formfields %} 11 | 12 | 13 |
14 | 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include requirements.txt 4 | include requirements_mturk.txt 5 | recursive-include otree/static * 6 | recursive-include otree/templates * 7 | recursive-include otree/project_template * 8 | recursive-include otree/app_template * 9 | recursive-include otree/app_template_lite *.html 10 | recursive-include otree/locale * 11 | recursive-include otree *.pyi 12 | recursive-exclude tests * -------------------------------------------------------------------------------- /otree/templates/otree/Rooms.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/BaseAdmin.html" %} 2 | 3 | {% block title %} 4 | Rooms 5 | {% endblock %} 6 | 7 | {% block content %} 8 | 9 |

Current rooms:

10 | 11 |
12 | {% for room in rooms %} 13 | {{ room.display_name }} 14 | {% endfor %} 15 |
16 | 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /otree/templates/otree/includes/SessionInfo.html: -------------------------------------------------------------------------------- 1 | {% if config.doc %} 2 |

Session description

3 |

4 | {{ config.doc|safe }} 5 |

6 | {% endif %} 7 | 8 |

App sequence

9 | 10 | 11 | {% for app in config.app_sequence_display() %} 12 | 13 | 14 | 19 | 20 | {% endfor %} 21 |
{{ app.name }} 15 |

16 | {{ app.doc|safe }} 17 |

18 |
22 | -------------------------------------------------------------------------------- /otree/static/otree/js/internet-explorer.js: -------------------------------------------------------------------------------- 1 | /** IE doesn't support Promises in ES5', bluebird alternative script can handle it */ 2 | if (navigator.appName == 'Microsoft Internet Explorer' || !!(navigator.userAgent.match(/Trident/) || navigator.userAgent.match(/rv 11/)) || (typeof $.browser !== "undefined" && $.browser.msie == 1)) { 3 | var script = document.createElement('script'); 4 | script.type = 'text/javascript'; 5 | script.src = 'https://cdnjs.cloudflare.com/ajax/libs/bluebird/3.3.5/bluebird.min.js'; 6 | $("body").append(script); 7 | } 8 | -------------------------------------------------------------------------------- /otree/app_template/_builtin/__init__.py: -------------------------------------------------------------------------------- 1 | # Don't change anything in this file. 2 | from .. import models 3 | import otree.api 4 | 5 | 6 | class Page(otree.api.Page): 7 | subsession: models.Subsession 8 | group: models.Group 9 | player: models.Player 10 | 11 | 12 | class WaitPage(otree.api.WaitPage): 13 | subsession: models.Subsession 14 | group: models.Group 15 | player: models.Player 16 | 17 | 18 | class Bot(otree.api.Bot): 19 | subsession: models.Subsession 20 | group: models.Group 21 | player: models.Player 22 | -------------------------------------------------------------------------------- /otree/api.py: -------------------------------------------------------------------------------- 1 | from otree.models import BaseSubsession, BaseGroup, BasePlayer # noqa 2 | from otree.constants import BaseConstants # noqa 3 | from otree.views import Page, WaitPage # noqa 4 | from otree.currency import Currency, currency_range, safe_json # noqa 5 | from otree.bots import Bot, Submission, SubmissionMustFail, expect # noqa 6 | from otree import database as models # noqa 7 | from otree.forms import widgets # noqa 8 | from otree.i18n import extract_otreetemplate # noqa 9 | from otree.database import ExtraModel # noqa 10 | from otree.read_csv import read_csv 11 | cu = Currency 12 | -------------------------------------------------------------------------------- /otree/app_template/models.py: -------------------------------------------------------------------------------- 1 | from otree.api import ( 2 | models, 3 | widgets, 4 | BaseConstants, 5 | BaseSubsession, 6 | BaseGroup, 7 | BasePlayer, 8 | Currency as c, 9 | currency_range, 10 | ) 11 | 12 | 13 | doc = """ 14 | Your app description 15 | """ 16 | 17 | 18 | class Constants(BaseConstants): 19 | name_in_url = '{{ app_name }}' 20 | players_per_group = None 21 | num_rounds = 1 22 | 23 | 24 | class Subsession(BaseSubsession): 25 | pass 26 | 27 | 28 | class Group(BaseGroup): 29 | pass 30 | 31 | 32 | class Player(BasePlayer): 33 | pass 34 | -------------------------------------------------------------------------------- /otree/app_template_lite/__init__.py: -------------------------------------------------------------------------------- 1 | from otree.api import * 2 | 3 | 4 | doc = """ 5 | Your app description 6 | """ 7 | 8 | 9 | class C(BaseConstants): 10 | NAME_IN_URL = '{{ app_name }}' 11 | PLAYERS_PER_GROUP = None 12 | NUM_ROUNDS = 1 13 | 14 | 15 | class Subsession(BaseSubsession): 16 | pass 17 | 18 | 19 | class Group(BaseGroup): 20 | pass 21 | 22 | 23 | class Player(BasePlayer): 24 | pass 25 | 26 | 27 | # PAGES 28 | class MyPage(Page): 29 | pass 30 | 31 | 32 | class ResultsWaitPage(WaitPage): 33 | pass 34 | 35 | 36 | class Results(Page): 37 | pass 38 | 39 | 40 | page_sequence = [MyPage, ResultsWaitPage, Results] 41 | -------------------------------------------------------------------------------- /otree/static/otree/js/live.js: -------------------------------------------------------------------------------- 1 | var liveSocket; 2 | 3 | var $currentScript = $('#otree-live'); 4 | 5 | var socketUrl = $currentScript.data('socketUrl'); 6 | 7 | liveSocket = makeReconnectingWebSocket(socketUrl); 8 | 9 | liveSocket.onmessage = function (e) { 10 | var data = JSON.parse(e.data); 11 | 12 | if (liveRecv !== undefined) { 13 | liveRecv(data); 14 | } 15 | }; 16 | 17 | function liveSend(msg) { 18 | liveSocket.send(JSON.stringify(msg)); 19 | } 20 | 21 | // prevent form submission when user presses enter in an input 22 | $(document).ready(function() { 23 | $('input').on('keypress', function (e) { 24 | if (e.key === 'Enter') { 25 | e.preventDefault(); 26 | } 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /otree/static/otree/js/jquery.timeago.en-short.js: -------------------------------------------------------------------------------- 1 | (function (factory) { 2 | if (typeof define === 'function' && define.amd) { 3 | define(['jquery'], factory); 4 | } else if (typeof module === 'object' && typeof module.exports === 'object') { 5 | factory(require('jquery')); 6 | } else { 7 | factory(jQuery); 8 | } 9 | }(function (jQuery) { 10 | // English shortened 11 | jQuery.timeago.settings.strings = { 12 | prefixAgo: null, 13 | prefixFromNow: null, 14 | suffixAgo: "", 15 | suffixFromNow: "", 16 | seconds: "1m", 17 | minute: "1m", 18 | minutes: "%dm", 19 | hour: "1h", 20 | hours: "%dh", 21 | day: "1d", 22 | days: "%dd", 23 | month: "1mo", 24 | months: "%dmo", 25 | year: "1yr", 26 | years: "%dyr", 27 | wordSeparator: " ", 28 | numbers: [] 29 | }; 30 | })); -------------------------------------------------------------------------------- /otree/templates/otree/RoomWithSession.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/BaseAdmin.html" %} 2 | 3 | {% block title %} 4 | Room: {{ room.display_name }} 5 | {% endblock %} 6 | 7 | {% block content %} 8 | 9 |
10 | {% csrf_token %} 11 | 12 |
13 |
14 | 17 |
18 |
19 | 22 |
23 |
24 | 25 |
26 | 27 | {% include "otree/includes/RoomParticipantLinks.html" %} 28 | 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /otree/cli/base.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | from importlib import import_module 3 | import sys 4 | 5 | 6 | class BaseCommand: 7 | def outer_handle(self, args): 8 | parser = self._create_parser() 9 | options = parser.parse_args(args) 10 | return self.handle(**vars(options)) 11 | 12 | def handle(self, *args, **options): 13 | raise NotImplementedError 14 | 15 | def _create_parser(self): 16 | parser = ArgumentParser() 17 | self.add_arguments(parser) 18 | return parser 19 | 20 | def add_arguments(self, parser): 21 | """ 22 | Entry point for subclassed cli to add custom arguments. 23 | """ 24 | pass 25 | 26 | 27 | def call_command(cmd, *args): 28 | try: 29 | module = import_module(f'otree.cli.{cmd}') 30 | except ModuleNotFoundError: 31 | sys.exit(f"No command named '{cmd}'") 32 | module.Command().outer_handle(args) 33 | -------------------------------------------------------------------------------- /otree/channels/routing.py: -------------------------------------------------------------------------------- 1 | from starlette.routing import WebSocketRoute as WSR 2 | from . import consumers as cs 3 | 4 | websocket_routes = [ 5 | WSR('/wait_page', cs.WSGroupWaitPage), 6 | WSR('/subsession_wait_page', cs.WSSubsessionWaitPage), 7 | WSR('/group_by_arrival_time', cs.WSGroupByArrivalTime), 8 | WSR('/auto_advance', cs.DetectAutoAdvance), 9 | WSR('/create_session', cs.WSCreateSession), 10 | WSR('/create_demo_session', cs.WSCreateDemoSession), 11 | WSR('/delete_sessions', cs.WSDeleteSessions), 12 | WSR('/wait_for_session_in_room', cs.WSRoomParticipant), 13 | WSR('/room_without_session/{room_name}', cs.WSRoomAdmin), 14 | WSR('/session_monitor/{code}', cs.WSSessionMonitor), 15 | WSR('/browser_bots_client/{session_code}', cs.WSBrowserBotsLauncher), 16 | WSR('/browser_bot_wait', cs.WSBrowserBot), 17 | WSR('/live', cs.LiveConsumer), 18 | WSR('/chat', cs.WSChat), 19 | WSR('/export', cs.WSExportData), 20 | ] 21 | -------------------------------------------------------------------------------- /otree/templates/otree/RoomInputLabel.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/Base.html" %} 2 | 3 | 4 | {% block body_main %} 5 |
6 |

7 | {% block title %}{{ 'Welcome'|gettext }}{% endblock %} 8 |

9 |
10 |
11 | {% if invalid_label %} 12 | {% comment %}Translators: If the user enters an invalid participant label{% endcomment %} 13 |

{{ 'This participant label was not found'|gettext }}

14 | {% endif %} 15 | 16 |
17 | 19 |
20 | {% next_button %} 21 |
22 | 23 |
24 |
25 | {% endblock %} -------------------------------------------------------------------------------- /otree/templates/otree/AdminReport.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/Session.html" %} 2 | 3 | 4 | 5 | {% block content %} 6 | {{ super() }} 7 |
8 | 9 | 10 | 11 | 12 | 17 | 18 |
{% formfield 'app_name' %}{% formfield 'round_number' %} 13 | 16 |
19 |
20 | 21 | {% include user_template %} 22 | 23 | {% if is_debug %} 24 |

25 | {% include 'otree/includes/debug_info.html' %} 26 | {% endif %} 27 | 28 | 35 | 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /otree/static/otree/css/table.css: -------------------------------------------------------------------------------- 1 | #monitor-table th, #monitor-table td, #results-table th, #results-table td { 2 | text-align: center; 3 | word-wrap: break-word; 4 | } 5 | 6 | table.draggable { 7 | display: block; 8 | overflow-x: auto; 9 | margin-bottom: 0; 10 | cursor: move; 11 | cursor: grab; 12 | cursor: -moz-grab; 13 | cursor: -webkit-grab; 14 | user-select: none; 15 | -webkit-user-select: none; 16 | -moz-user-select: none; 17 | -ms-user-select: none; 18 | } 19 | 20 | table.draggable.grabbing { 21 | cursor: move; 22 | } 23 | 24 | 25 | /* the 104px comes from 940/9...it's hardcoded to fit the current container 26 | size...maybe there is a better way */ 27 | 28 | table.draggable td, table.draggable th { 29 | min-width: 104px; 30 | max-width: 104px 31 | } 32 | 33 | 34 | table.draggable thead { 35 | display: block; 36 | } 37 | 38 | table.draggable tbody { 39 | display: block; 40 | max-height: 450px; 41 | overflow-y: auto; 42 | overflow-x: hidden; 43 | } 44 | -------------------------------------------------------------------------------- /otree/static/otree/js/page-websocket-redirect.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function () { 2 | 3 | // i also considered using document.currentScript.getAttribute() 4 | // as described here: https://stackoverflow.com/a/32589923/38146 5 | // but PyCharm doesn't like that the script has non data- params 6 | // maybe non-standard? 7 | var $currentScript = $('#websocket-redirect'); 8 | 9 | var socketUrl = $currentScript.data('socketUrl'); 10 | var isBrowserBot = $currentScript.data('isBrowserBot'); 11 | var isDebug = $currentScript.data('isDebug'); 12 | 13 | /* 14 | One user reported that with a 588 bot session, 15 | web socket for auto-advance adds 4s to each page load. 16 | */ 17 | var socket; 18 | 19 | function initWebSocket() { 20 | socket = makeReconnectingWebSocket(socketUrl); 21 | socket.onmessage = function (e) { 22 | var data = JSON.parse(e.data); 23 | 24 | if (data.auto_advanced) { 25 | window.location.reload(); 26 | } 27 | }; 28 | 29 | } 30 | 31 | initWebSocket(); 32 | }); 33 | -------------------------------------------------------------------------------- /otree/templates/otree/DemoIndex.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/BaseAdmin.html" %} 2 | 3 | {% block title %} 4 | {{ title }} 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | 10 |
11 |
12 | {% for s in session_info %} 13 | 14 | {{ s.display_name }} 15 | 16 | {% endfor %} 17 |
18 |
19 |
20 |
21 | {% if otreehub_url %} 22 | 25 | {% endif %} 26 |
27 | {{ intro_html|safe }} 28 |
29 | {% if is_debug %} 30 |
31 | To add to this list, create a new session config. 32 |
33 | {% endif %} 34 |
35 |
36 | 37 |
38 | {% endblock %} 39 | 40 | -------------------------------------------------------------------------------- /otree/templates/otree/includes/debug_info.html: -------------------------------------------------------------------------------- 1 | 2 | {# spaces to make debug info less distracting #} 3 |
4 |
5 |
6 | 7 | 8 |
{# .debug-info so it's visible in inspector #} 9 |
Debug info
10 | 11 | {% include 'otree/includes/hidden_form_errors.html' %} 12 | 13 | {% if is_defined('view.debug_tables') %} 14 | {% for table in view.debug_tables %} 15 |
16 |
{{ table.title }}
17 |
18 | 19 | {% for k, v in table.rows %} 20 | 21 | 22 | 25 | 26 | {% endfor %} 27 |
{{ k }} 23 | {{ v }} 24 |
28 |
29 |
30 | {% endfor %} 31 | {% endif %} 32 |
33 | -------------------------------------------------------------------------------- /otree/templates/otree/includes/TimeLimit.js.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 26 | 27 | -------------------------------------------------------------------------------- /otree/project_template/settings.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | 3 | SESSION_CONFIGS = [ 4 | # dict( 5 | # name='public_goods', 6 | # app_sequence=['public_goods'], 7 | # num_demo_participants=3, 8 | # ), 9 | ] 10 | 11 | # if you set a property in SESSION_CONFIG_DEFAULTS, it will be inherited by all configs 12 | # in SESSION_CONFIGS, except those that explicitly override it. 13 | # the session config can be accessed from methods in your apps as self.session.config, 14 | # e.g. self.session.config['participation_fee'] 15 | 16 | SESSION_CONFIG_DEFAULTS = dict( 17 | real_world_currency_per_point=1.00, participation_fee=0.00, doc="" 18 | ) 19 | 20 | PARTICIPANT_FIELDS = [] 21 | SESSION_FIELDS = [] 22 | 23 | # ISO-639 code 24 | # for example: de, fr, ja, ko, zh-hans 25 | LANGUAGE_CODE = 'en' 26 | 27 | # e.g. EUR, GBP, CNY, JPY 28 | REAL_WORLD_CURRENCY_CODE = 'USD' 29 | USE_POINTS = True 30 | 31 | ADMIN_USERNAME = 'admin' 32 | # for security, best to set admin password in an environment variable 33 | ADMIN_PASSWORD = environ.get('OTREE_ADMIN_PASSWORD') 34 | 35 | DEMO_PAGE_INTRO_HTML = """ """ 36 | 37 | SECRET_KEY = '{{ secret_key }}' 38 | -------------------------------------------------------------------------------- /otree/cli/browser_bots.py: -------------------------------------------------------------------------------- 1 | from .base import BaseCommand 2 | from otree.bots.browser_launcher import Launcher 3 | 4 | 5 | class Command(BaseCommand): 6 | help = "oTree: Run browser bots." 7 | 8 | def add_arguments(self, parser): 9 | parser.add_argument( 10 | 'session_config_name', 11 | nargs='?', 12 | help='If omitted, all sessions in SESSION_CONFIGS are run', 13 | ) 14 | ahelp = 'Number of participants. ' 'Defaults to minimum for the session config.' 15 | parser.add_argument('num_participants', type=int, nargs='?', help=ahelp) 16 | 17 | parser.add_argument( 18 | '--server-url', 19 | action='store', 20 | type=str, 21 | dest='server_url', 22 | default='http://127.0.0.1:8000', 23 | help="Server's root URL", 24 | ) 25 | 26 | def handle(self, session_config_name, num_participants, server_url, **options): 27 | 28 | launcher = Launcher( 29 | session_config_name=session_config_name, 30 | server_url=server_url, 31 | num_participants=num_participants, 32 | ) 33 | launcher.run() 34 | -------------------------------------------------------------------------------- /otree/templates/otree/MTurkHTMLQuestion.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | {% include user_template %} 6 | 30 | 31 | 32 | ]]> 33 | 34 | {{ frame_height }} 35 | -------------------------------------------------------------------------------- /otree/cli/unzip.py: -------------------------------------------------------------------------------- 1 | from .base import BaseCommand 2 | import tarfile 3 | import logging 4 | import os.path 5 | import sys 6 | from pathlib import Path 7 | 8 | print_function = print 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class Command(BaseCommand): 14 | help = "Unzip a zipped oTree project" 15 | 16 | def add_arguments(self, parser): 17 | parser.add_argument('zip_file', type=str, help="The .otreezip file") 18 | 19 | def handle(self, zip_file): 20 | output_folder = Path(zip_file).stem 21 | 22 | if Path(output_folder).exists(): 23 | sys.exit( 24 | f"Could not unzip the file; target folder '{output_folder}' already exists. " 25 | ) 26 | 27 | unzip(zip_file, output_folder) 28 | msg = f'Unzipped file. Enter this:\n' f'cd {esc_fn(output_folder)}\n' 29 | 30 | logger.info(msg) 31 | 32 | 33 | def esc_fn(fn): 34 | if ' ' in fn: 35 | return f'\"{fn}\"' 36 | return fn 37 | 38 | 39 | def unzip(zip_file: str, output_folder): 40 | if os.path.isfile('settings.py'): 41 | logger.error( 42 | 'You are trying to unzip a project but it seems you are ' 43 | 'already in a project folder (found settings.py).' 44 | ) 45 | sys.exit(-1) 46 | 47 | with tarfile.open(zip_file) as tar: 48 | tar.extractall(output_folder) 49 | -------------------------------------------------------------------------------- /otree/templates/otree/Login.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/BaseAdmin.html" %} 2 | {% block title %}Admin Login{% endblock %} 3 | 4 | {% block content %} 5 | {% if form.errors %} 6 | Login failed 7 | {% endif %} 8 | 9 | {% for w in warnings %} 10 |

{{ w }}

11 | {% endfor %} 12 | 13 |
14 |
15 | {% csrf_token %} 16 |
17 |
18 | 19 |
20 | 22 |
23 |
24 |
25 |
26 |
27 | 28 |
29 | 31 |
32 |
33 |
34 |
35 | 38 |
39 |
40 |
41 | 42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /otree/cli/create_session.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .base import BaseCommand 4 | 5 | from otree.session import create_session 6 | from otree.room import ROOM_DICT 7 | 8 | 9 | logger = logging.getLogger('otree') 10 | 11 | 12 | class Command(BaseCommand): 13 | help = "oTree: Create a session." 14 | 15 | def add_arguments(self, parser): 16 | parser.add_argument('session_config_name', help="The session config name") 17 | parser.add_argument( 18 | 'num_participants', 19 | type=int, 20 | help="Number of participants for the created session", 21 | ) 22 | parser.add_argument( 23 | "--room", 24 | action="store", 25 | dest="room_name", 26 | default=None, 27 | help="Name of room to create the session in", 28 | ) 29 | 30 | def handle(self, session_config_name, num_participants, room_name, **kwargs): 31 | 32 | session = create_session( 33 | session_config_name=session_config_name, num_participants=num_participants, 34 | ) 35 | 36 | if room_name: 37 | room = ROOM_DICT[room_name] 38 | room.set_session(session) 39 | logger.info( 40 | "Created session with code {} in room '{}'\n".format( 41 | session.code, room_name 42 | ) 43 | ) 44 | else: 45 | logger.info("Created session with code {}\n".format(session.code)) 46 | -------------------------------------------------------------------------------- /otree/templating/errors.py: -------------------------------------------------------------------------------- 1 | # Base class for all exception types raised by the template engine. 2 | class TemplateError(Exception): 3 | pass 4 | 5 | 6 | # This exception type may be raised while attempting to load a template file. 7 | class TemplateLoadError(TemplateError): 8 | pass 9 | 10 | 11 | # This exception type is raised if the lexer cannot tokenize a template string. 12 | class TemplateLexingError(TemplateError): 13 | def __init__(self, msg, template_id, line_number): 14 | super().__init__(msg) 15 | self.template_id = template_id 16 | self.line_number = line_number 17 | self.msg = msg 18 | 19 | def __str__(self): 20 | return f'{self.msg} (line {self.line_number})' 21 | 22 | 23 | class ErrorWithToken(TemplateError): 24 | def __init__(self, msg, token): 25 | super().__init__() 26 | self.msg = msg 27 | self.token = token 28 | 29 | def __str__(self): 30 | token = self.token 31 | return f'{self.msg} (line {token.line_number}, in "{token.keyword}")' 32 | 33 | 34 | # This exception type may be raised while a template is being compiled. 35 | class TemplateSyntaxError(ErrorWithToken): 36 | pass 37 | 38 | 39 | # This exception type may be raised while a template is being rendered. 40 | class TemplateRenderingError(ErrorWithToken): 41 | pass 42 | 43 | 44 | # This exception type is raised in strict mode if a variable cannot be resolved. 45 | class UndefinedVariable(ErrorWithToken): 46 | pass 47 | -------------------------------------------------------------------------------- /otree/templates/otree/includes/mturk_payment_table.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | {% if reviewable %} 17 | 26 | {% endif %} 27 | 28 | 29 | {% for p in participants %} 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 40 | 41 | {% if reviewable %} 42 | 52 | {% endif %} 53 | 54 | {% endfor %} 55 |
CodeAssignment IDWorker IDProgressCompletion code 13 | Bonus payoff 14 | Total pay 18 | Select 19 |
20 | 24 |
25 |
{{ forloop.counter }}{{ p.code }}{{ p.mturk_assignment_id || "" }}{{ p.mturk_worker_id || "" }}{{ p.current_page_() }}{{ p.mturk_answers_formatted }} 38 | {{ p.payoff_in_real_world_currency() }} 39 | {{ p.payoff_plus_participation_fee() }} 43 |
44 | 50 |
51 |
56 | -------------------------------------------------------------------------------- /otree/templating/template.py: -------------------------------------------------------------------------------- 1 | from . import compiler 2 | from . import context 3 | from . import nodes 4 | 5 | 6 | class Template: 7 | def __init__( 8 | self, template_string, template_id="UNIDENTIFIED", template_type: str = '' 9 | ): 10 | is_page_template = template_type in ['Page', 'WaitPage'] 11 | 12 | self.root_node = compiler.compile( 13 | template_string, template_id, is_page_template=is_page_template 14 | ) 15 | children = self.root_node.children 16 | # this is so that {% extends 'otree/Page.html' %} can be omitted 17 | if ( 18 | template_type 19 | and children 20 | and not isinstance(children[0], nodes.ExtendsNode) 21 | ): 22 | extends = f'otree/{template_type}.html' 23 | 24 | token = compiler.Token( 25 | 'INSTRUCTION', f'extends "{extends}"', template_id, 1 26 | ) 27 | self.root_node.children.insert(0, nodes.ExtendsNode(token=token)) 28 | 29 | self.block_registry = self._register_blocks(self.root_node, {}) 30 | 31 | def __str__(self): 32 | return str(self.root_node) 33 | 34 | def render(self, *pargs, **kwargs): 35 | data_dict = pargs[0] if pargs else kwargs 36 | return self.root_node.render(context.Context(data_dict, self)) 37 | 38 | def _register_blocks(self, node, registry): 39 | if isinstance(node, nodes.BlockNode): 40 | registry.setdefault(node.title, []).append(node) 41 | for child in node.children: 42 | self._register_blocks(child, registry) 43 | return registry 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Daniel Li Chen, Martin Walter Schonger, Christopher Wickens. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | 21 | The Licensee undertakes to mention the name oTree, the names of the licensors 22 | (Daniel L. Chen, Martin Schonger and Christopher Wickens) and to cite the 23 | following article in all publications in which results of experiments conducted 24 | with the Software are published: Chen, Daniel L., Martin Schonger, and Chris Wickens. 25 | 2016. "oTree - An open-source platform for laboratory, online, and field experiments." 26 | Journal of Behavioral and Experimental Finance, vol 9: 88-97. 27 | -------------------------------------------------------------------------------- /otree/views/demo.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from otree import settings 4 | from otree.session import SESSION_CONFIGS_DICT 5 | from .cbv import AdminView 6 | 7 | 8 | class DemoIndex(AdminView): 9 | url_pattern = '/demo' 10 | 11 | def vars_for_template(self): 12 | title = getattr(settings, 'DEMO_PAGE_TITLE', 'Demo') 13 | intro_html = getattr(settings, 'DEMO_PAGE_INTRO_HTML', '') 14 | session_info = [] 15 | for session_config in SESSION_CONFIGS_DICT.values(): 16 | session_info.append( 17 | { 18 | 'name': session_config['name'], 19 | 'display_name': session_config['display_name'], 20 | 'url': self.request.url_for( 21 | 'CreateDemoSession', config_name=session_config['name'] 22 | ), 23 | 'num_demo_participants': session_config['num_demo_participants'], 24 | } 25 | ) 26 | 27 | if os.environ.get('OTREEHUB_PUB'): 28 | otreehub_app_name = os.environ.get('OTREEHUB_APP_NAME') 29 | otreehub_url = f'https://www.otreehub.com/projects/{otreehub_app_name}/' 30 | else: 31 | otreehub_url = '' 32 | 33 | return dict( 34 | session_info=session_info, 35 | title=title, 36 | intro_html=intro_html, 37 | is_debug=settings.DEBUG, 38 | otreehub_url=otreehub_url, 39 | ) 40 | 41 | 42 | class CreateDemoSession(AdminView): 43 | url_pattern = '/demo/{config_name}' 44 | 45 | def vars_for_template(self): 46 | return dict(config_name=self.request.path_params['config_name']) 47 | -------------------------------------------------------------------------------- /otree/static/otree/js/formInputs.js: -------------------------------------------------------------------------------- 1 | var _FORM_INPUTS_NAME = 'forminputs'; 2 | 3 | window[_FORM_INPUTS_NAME] = (function () { 4 | function _innerGet(target, prop, receiver) { 5 | // https://stackoverflow.com/questions/27983023/ 6 | // it works but i don't get why it makes a difference for getting input.value 7 | if (prop in target) { 8 | let ret = target[prop]; 9 | if (typeof ret === 'function') { 10 | // DOM methods sometimes aren't instances of Function, so we can't directly use func.bind() 11 | ret = Function.prototype.bind.call(ret, target); 12 | } 13 | return ret; 14 | } 15 | } 16 | 17 | return new Proxy(document.getElementById('form').elements, { 18 | set: function (obj, prop, value) { 19 | throw new TypeError(`To set the value of a field, you must use .value, for example, formInputs.${prop}.value = ...`); 20 | }, 21 | get: function (target, prop, receiver) { 22 | var input = Reflect.get(...arguments); 23 | if (input == null) { 24 | throw `Field "${prop}" does not exist.` 25 | } 26 | var proxyInput = new Proxy(input, { 27 | set: function (obj, prop2, value) { 28 | if (!(prop2 in obj) && NodeList.prototype.isPrototypeOf(obj)) { 29 | throw Error(`${_FORM_INPUTS_NAME}.${prop} has no property '${prop2}'. (Note that it is a RadioNodeList, not a regular input.) `) 30 | } 31 | obj[prop2] = value; 32 | }, 33 | get: _innerGet 34 | }); 35 | return proxyInput; 36 | }, 37 | }); 38 | })(); 39 | -------------------------------------------------------------------------------- /otree/templates/otree/includes/RoomParticipantLinks.html: -------------------------------------------------------------------------------- 1 |

Persistent URLs

2 | 3 |

4 | These URLs will stay constant for new sessions, 5 | even if the database is recreated. 6 |

7 | 8 | 9 | {% if room.has_participant_labels %} 10 |

Participant-specific URLs

11 | 12 |

13 | These URLs contain the labels, 14 | so participants don't have to enter their label manually. 15 |

16 | 17 |
18 | Show/hide 19 | 20 | {% comment %} 21 | this is a table to be consistent with other start links listings, 22 | which may include participant label column 23 | {% endcomment %} 24 | 25 | {% for participant_url in participant_urls %} 26 | 27 | {# eventually add participant labels #} 28 | 29 | 30 | {% endfor %} 31 |
{{ participant_url }}
32 | 33 |
34 | {% endif %} 35 | {% if room.use_secure_urls %} 36 | {# then dont use room-wide URL #} 37 | {% else %} 38 |
39 |

Room-wide URL

40 |

41 | Here is the room-wide URL anyone can use. 42 | {% if room.has_participant_labels %} 43 | Users will be prompted to enter their participant label, 44 | which will be validated against your participant_label_file. 45 | {% else %} 46 | Don't use this link when testing in multiple tabs on the same browser, 47 | because all tabs will be assigned to the same participant, 48 | using a cookie. 49 | {% endif %} 50 |

51 | 52 |

{{ room_wide_url }}

53 | {% endif %} 54 | 55 |
56 | 57 | 58 | -------------------------------------------------------------------------------- /otree/templates/otree/Base.html: -------------------------------------------------------------------------------- 1 | 2 | {# NOTE: keep this compact so that view-source is friendly for users #} 3 | 4 | 5 | 6 | {% block head_title %}{% endblock %} 7 | {% block internal_styles %} 8 | 9 | 10 | 11 | {% comment %} 12 | this actually belongs in internal_scripts, but we are in the process 13 | of deprecating it, so some people's apps might still rely on jQuery being 14 | available within the content block. 15 | {% endcomment %} 16 | 17 | {% endblock %} 18 | {# these blocks are for public API #} 19 | {% block global_styles %}{% endblock %} 20 | {% block app_styles %}{% endblock %} 21 | {% block styles %}{% endblock %} 22 | 23 | 24 | {% block body_main %}{% endblock %} 25 | 26 | {% block internal_scripts %} 27 | 28 | 29 | {% block bootstrap_scripts %} 30 | 31 | {% endblock %} 32 | 33 | {% endblock %} 34 | {% block live %}{% endblock %} 35 | {# these blocks are for public API #} 36 | {% block global_scripts %}{% endblock %} 37 | {% block app_scripts %}{% endblock %} 38 | {% block scripts %}{% endblock %} 39 | 40 | 41 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | `Homepage`_ 2 | 3 | These are the core oTree libraries. 4 | 5 | Before you fork this project, keep in mind that otree is updated 6 | frequently, and over time you might get upstream merge conflicts, as 7 | your local project diverges from the oTree mainline version. 8 | 9 | Instead, consider creating a project with ``otree startproject`` and 10 | making your modifications in an app, using oTree’s public API. You can 11 | create custom URLs, channels, override settings, etc. 12 | 13 | Docs 14 | ---- 15 | 16 | http://otree.readthedocs.io/en/latest/index.html 17 | 18 | Quickstart 19 | ---------- 20 | 21 | Typical setup 22 | ~~~~~~~~~~~~~ 23 | 24 | :: 25 | 26 | pip install -U otree 27 | otree startproject oTree 28 | cd oTree 29 | otree devserver 30 | 31 | Core dev setup 32 | ~~~~~~~~~~~~~~ 33 | 34 | If you are modifying otree-core locally, clone or download this repo, 35 | then run this from the project root: 36 | 37 | :: 38 | 39 | pip install -e . 40 | cd .. # or wherever you will start your project 41 | otree startproject oTree 42 | cd oTree 43 | otree devserver 44 | 45 | i18n 46 | ~~~~ 47 | 48 | To generate .pot and update .po files:: 49 | 50 | cd tests 51 | pybabel extract "../otree" -o "../otree/locale/django.pot" -F "..\otree\locale\babel.ini" -k core_gettext -c Translators: 52 | cd .. 53 | pybabel update -D django -i otree/locale/django.pot -d otree/locale 54 | 55 | To compile .po to .mo:: 56 | 57 | pybabel compile -d otree/locale -f -D django 58 | 59 | Note, beware of the issue 60 | `here `__ 61 | 62 | To add a new language (e.g. Polish):: 63 | 64 | pybabel init -D django -i otree/locale/django.pot -d otree/locale -l pl 65 | 66 | .. _Homepage: http://www.otree.org/ 67 | -------------------------------------------------------------------------------- /otree/templates/otree/SessionPayments.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/Session.html" %} 2 | {% block content %} 3 | {{ super() }} 4 |
5 | 6 |

Session

7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
Session config{{ session.config.name }}
Session code{{ session.code }}
Participation fee{{ participation_fee }}
24 | 25 |

Participants

26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {% if show_finished_status %} {% endif %} 34 | 35 | 36 | 37 | 38 | {% for p in participants %} 39 | 40 | 41 | 42 | 43 | {% if show_finished_status %} {% endif %} 44 | 45 | 46 | 47 | {% endfor %} 48 | 49 |
CodeLabelProgressFinishedPayoff (bonus)Total
{{ p.code }}{{ p.label || "" }}{{ p.current_page_() }}{{ p._get_finished() ?? "1" :: "" }}{{ p.payoff_in_real_world_currency() }}{{ p.payoff_plus_participation_fee() }}
50 | 51 |

Summary

52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
Total payments{{ total_payments }}
Mean payment{{ mean_payment }}
62 |
63 | {% endblock %} 64 | -------------------------------------------------------------------------------- /otree/cli/prodserver1of2.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import subprocess 4 | 5 | from .base import BaseCommand 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | print_function = print 10 | 11 | 12 | def run_asgi_server(addr, port, *, is_devserver=False): 13 | run_uvicorn(addr, port, is_devserver=is_devserver) 14 | 15 | 16 | def run_uvicorn(addr, port, *, is_devserver): 17 | from uvicorn.main import Config, Server 18 | 19 | config = Config( 20 | 'otree.asgi:app', 21 | host=addr, 22 | port=int(port), 23 | log_level='warning' if is_devserver else "info", 24 | log_config=None, # oTree has its own logger 25 | # i suspect it was defaulting to something else 26 | workers=1, 27 | # websockets library handles disconnects & ping automatically, 28 | # so we can simplify code and also avoid H15 errors on heroku. 29 | ws='websockets', 30 | # ws='wsproto', 31 | ) 32 | server = Server(config=config) 33 | server.run() 34 | 35 | 36 | def get_addr_port(cli_addrport, is_devserver=False): 37 | default_addr = '127.0.0.1' if is_devserver else '0.0.0.0' 38 | default_port = os.environ.get('PORT') or 8000 39 | if not cli_addrport: 40 | return default_addr, default_port 41 | parts = cli_addrport.split(':') 42 | if len(parts) == 1: 43 | return default_addr, parts[0] 44 | return parts 45 | 46 | 47 | class Command(BaseCommand): 48 | def add_arguments(self, parser): 49 | parser.add_argument( 50 | 'addrport', nargs='?', help='Optional port number, or ipaddr:port' 51 | ) 52 | 53 | def handle(self, *args, addrport=None, verbosity=1, **kwargs): 54 | addr, port = get_addr_port(addrport) 55 | subprocess.Popen( 56 | ['otree', 'timeoutsubprocess', str(port)], env=os.environ.copy() 57 | ) 58 | print_function('Running prodserver') 59 | run_asgi_server(addr, port) 60 | -------------------------------------------------------------------------------- /otree/middleware.py: -------------------------------------------------------------------------------- 1 | from starlette.middleware.base import BaseHTTPMiddleware 2 | from starlette.middleware import Middleware 3 | from starlette.middleware.sessions import SessionMiddleware 4 | import time 5 | from starlette.requests import Request 6 | import logging 7 | from otree.database import db, NEW_IDMAP_EACH_REQUEST 8 | from otree.common import _SECRET, lock 9 | import asyncio 10 | import threading 11 | 12 | logger = logging.getLogger('otree.perf') 13 | 14 | 15 | lock2 = asyncio.Lock() 16 | 17 | 18 | class CommitTransactionMiddleware(BaseHTTPMiddleware): 19 | async def dispatch(self, request, call_next): 20 | async with lock2: 21 | if NEW_IDMAP_EACH_REQUEST: 22 | db.new_session() 23 | response = await call_next(request) 24 | if response.status_code < 500: 25 | db.commit() 26 | else: 27 | # it's necessary to roll back. if i don't, the values get saved to DB 28 | # (even though i don't commit, not sure...) 29 | db.rollback() 30 | # closing seems to interfere with errors middleware, which tries to get the value of local vars 31 | # and therefore queries the db 32 | # maybe it's not necessary to close since we just overwrite. 33 | # finally: 34 | # db.close() 35 | return response 36 | 37 | 38 | class PerfMiddleware(BaseHTTPMiddleware): 39 | async def dispatch(self, request, call_next): 40 | start = time.time() 41 | 42 | response = await call_next(request) 43 | 44 | # heroku has 'X-Request-ID' 45 | request_id = request.headers.get('X-Request-ID') 46 | if request_id: 47 | # only log this info on Heroku 48 | elapsed = time.time() - start 49 | msec = int(elapsed * 1000) 50 | msg = f'own_time={msec}ms request_id={request_id}' 51 | logger.info(msg) 52 | 53 | return response 54 | -------------------------------------------------------------------------------- /otree/templates/otree/ServerCheck.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/BaseAdmin.html" %} 2 | 3 | {% block title %} 4 | Server Readiness Checks 5 | {% endblock %} 6 | 7 | {% block content %} 8 | 9 | {% if pypi_results.installed == pypi_results.newest %} 10 |
11 | You have the latest version of oTree ({{ pypi_results.installed }}). 12 |
13 | {% else %} 14 |
15 | You have oTree {{ pypi_results.installed }}. 16 | The newest version is {{ pypi_results.newest|| "(unknown_pypi_connection_error)" }}. 17 |
18 | {% endif %} 19 | 20 | {% if debug %} 21 |
22 | DEBUG mode is on 23 | You should only use DEBUG mode when testing. 24 | Before launching a study, you should switch DEBUG mode off 25 | by setting the environment variable OTREE_PRODUCTION. 26 |
27 | {% else %} 28 |
29 | DEBUG mode is off 30 |
31 | {% endif %} 32 | 33 | {% if not auth_level_ok %} 34 |
35 | No password protection 36 | To prevent unauthorized server access, you should 37 | set the environment variable OTREE_AUTH_LEVEL. 38 |
39 | {% else %} 40 |
41 | Password protection is on. 42 | Your app's AUTH_LEVEL is {{ auth_level }}. 43 |
44 | {% endif %} 45 | 46 | {% if not is_postgres %} 47 |
48 | Not using PostgreSQL 49 | Your database is {{ backend_name }}, not postgres. 50 | When launching a study, it's recommended to use Postgres. 51 |
52 | {% else %} 53 |
54 | Postgres is configured. 55 |
56 | {% endif %} 57 | 58 | 59 | {% endblock %} 60 | 61 | -------------------------------------------------------------------------------- /otree/cli/bots.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from otree.database import session_scope 3 | 4 | from otree.bots.runner import run_all_bots_for_session_config 5 | from otree.constants import AUTO_NAME_BOTS_EXPORT_FOLDER 6 | from .base import BaseCommand 7 | 8 | logger = logging.getLogger('otree') 9 | MSG_BOTS_HELP = 'Run oTree bots' 10 | 11 | 12 | class Command(BaseCommand): 13 | help = MSG_BOTS_HELP 14 | 15 | def add_arguments(self, parser): 16 | # Positional arguments 17 | parser.add_argument( 18 | 'session_config_name', 19 | nargs='?', 20 | help='If omitted, all sessions in SESSION_CONFIGS are run', 21 | ) 22 | 23 | parser.add_argument( 24 | 'num_participants', 25 | type=int, 26 | nargs='?', 27 | help='Number of participants (if omitted, use num_demo_participants)', 28 | ) 29 | 30 | # don't call it --data because then people might think that 31 | # that's the *input* data folder 32 | parser.add_argument( 33 | '--export', 34 | nargs='?', 35 | const=AUTO_NAME_BOTS_EXPORT_FOLDER, 36 | dest='export_path', 37 | help=( 38 | 'Saves the data generated by the tests. ' 39 | 'Runs the "export data" command, ' 40 | 'outputting the CSV files to the specified directory, ' 41 | 'or an auto-generated one.' 42 | ), 43 | ) 44 | parser.add_argument( 45 | '--save', 46 | nargs='?', 47 | const=AUTO_NAME_BOTS_EXPORT_FOLDER, 48 | dest='export_path', 49 | help=('Alias for --export.'), 50 | ) 51 | 52 | def handle(self, *, session_config_name, num_participants, export_path, **options): 53 | 54 | run_all_bots_for_session_config( 55 | session_config_name=session_config_name, 56 | num_participants=num_participants, 57 | export_path=export_path, 58 | ) 59 | -------------------------------------------------------------------------------- /otree/templating/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | # Splits a string on instances of a delimiter character. Ignores quoted delimiters. 5 | def splitc(s, delimiter, strip=False, discard_empty=False, maxsplit=-1): 6 | 7 | tokens, buf, expecting, escaped = [], [], None, False 8 | 9 | for index, char in enumerate(s): 10 | if expecting: 11 | buf.append(char) 12 | if char == expecting and not escaped: 13 | expecting = None 14 | else: 15 | if char == delimiter: 16 | tokens.append(''.join(buf)) 17 | buf = [] 18 | if len(tokens) == maxsplit: 19 | buf.append(s[index+1:]) 20 | break 21 | else: 22 | buf.append(char) 23 | if char in ('"', "'"): 24 | expecting = char 25 | escaped = not escaped if char == '\\' else False 26 | 27 | tokens.append(''.join(buf)) 28 | 29 | if strip: 30 | tokens = [t.strip() for t in tokens] 31 | 32 | if discard_empty: 33 | tokens = [t for t in tokens if t] 34 | 35 | return tokens 36 | 37 | 38 | # Splits a string using a list of regular expression patterns. Ignores quoted delimiter matches. 39 | def splitre(s, delimiters, keepdels=False): 40 | 41 | tokens, buf = [], [] 42 | end_last_match = 0 43 | 44 | pattern = r'''"(?:[^\\"]|\\.)*"|'(?:[^\\']|\\.)*'|%s''' 45 | pattern %= '|'.join(delimiters) 46 | 47 | for match in re.finditer(pattern, s): 48 | if match.group()[0] in ["'", '"']: 49 | buf.append(s[end_last_match:match.end()]) 50 | end_last_match = match.end() 51 | continue 52 | buf.append(s[end_last_match:match.start()]) 53 | tokens.append(''.join(buf)) 54 | buf = [] 55 | end_last_match = match.end() 56 | if keepdels: 57 | tokens.append(match.group()) 58 | 59 | buf.append(s[end_last_match:]) 60 | tokens.append(''.join(buf)) 61 | 62 | return tokens 63 | -------------------------------------------------------------------------------- /otree/patch.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import starlette.exceptions 4 | from starlette.concurrency import run_in_threadpool 5 | from starlette.exceptions import HTTPException 6 | from starlette.requests import Request 7 | from starlette.types import Message, Receive, Scope, Send 8 | 9 | 10 | class ExceptionMiddleware(starlette.exceptions.ExceptionMiddleware): 11 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 12 | """oTree just removed the 'from None'. everything else is the same 13 | Need this until https://github.com/encode/starlette/issues/1114 is fixed 14 | """ 15 | if scope["type"] != "http": 16 | await self.app(scope, receive, send) 17 | return 18 | 19 | response_started = False 20 | 21 | async def sender(message: Message) -> None: 22 | nonlocal response_started 23 | 24 | if message["type"] == "http.response.start": 25 | response_started = True 26 | await send(message) 27 | 28 | try: 29 | await self.app(scope, receive, sender) 30 | except Exception as exc: 31 | handler = None 32 | 33 | if isinstance(exc, HTTPException): 34 | handler = self._status_handlers.get(exc.status_code) 35 | 36 | if handler is None: 37 | handler = self._lookup_exception_handler(exc) 38 | 39 | if handler is None: 40 | # oTree changed this part only 41 | raise exc # from None 42 | 43 | if response_started: 44 | msg = "Caught handled exception, but response already started." 45 | raise RuntimeError(msg) from exc 46 | 47 | request = Request(scope, receive=receive) 48 | if asyncio.iscoroutinefunction(handler): 49 | response = await handler(request, exc) 50 | else: 51 | response = await run_in_threadpool(handler, request, exc) 52 | await response(scope, receive, sender) 53 | -------------------------------------------------------------------------------- /otree/static/otree/js/common.js: -------------------------------------------------------------------------------- 1 | function makeReconnectingWebSocket(path) { 2 | // https://github.com/pladaria/reconnecting-websocket/issues/91#issuecomment-431244323 3 | var ws_scheme = window.location.protocol === "https:" ? "wss" : "ws"; 4 | var ws_path = ws_scheme + '://' + window.location.host + path; 5 | var socket = new ReconnectingWebSocket(ws_path); 6 | socket.onclose = function (e) { 7 | if (e.code === 1011) { 8 | // this may or may not exist in child pages. 9 | var serverErrorDiv = document.getElementById("websocket-server-error"); 10 | if (serverErrorDiv) { 11 | // better to put the message here rather than the div, otherwise it's confusing when 12 | // you do "view source" and there's an error message. 13 | serverErrorDiv.innerText = "Server error. Check the server logs or Sentry."; 14 | serverErrorDiv.style.visibility = "visible"; 15 | } 16 | } 17 | }; 18 | return socket; 19 | } 20 | 21 | (function () { 22 | 'use strict'; 23 | 24 | $(document).ready(function () { 25 | $('title').text($('#_otree-title').text()); 26 | 27 | // block the user from spamming the next button which can make congestion 28 | // problems worse. 29 | // i can't use $('.otree-btn-next').click() 30 | // because disabling the button inside the handler interferes with form 31 | // submission. 32 | $('#form').submit(function () { 33 | $('.otree-btn-next').each(function () { 34 | var nextButton = this; 35 | var originalState = nextButton.disabled; 36 | nextButton.disabled = true; 37 | setTimeout(function () { 38 | // restore original state. 39 | // it's possible the button was disabled in the first place? 40 | nextButton.disabled = originalState; 41 | }, 15000); 42 | }); 43 | }); 44 | }); 45 | 46 | })(); 47 | -------------------------------------------------------------------------------- /otree/update.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | from otree import __version__ 6 | 7 | 8 | def split_dotted_version(version): 9 | return tuple([int(n) for n in version.split('.')]) 10 | 11 | 12 | def check_update_needed( 13 | requirements_path: Path, current_version=__version__ 14 | ) -> Optional[str]: 15 | '''rewrote this without pkg_resources since that takes 0.4 seconds just to import''' 16 | 17 | if not requirements_path.exists(): 18 | return 19 | 20 | try: 21 | current_version_tuple = split_dotted_version(current_version) 22 | except ValueError: 23 | return 24 | 25 | for line in requirements_path.read_text('utf8').splitlines(): 26 | res = check_update_needed_line(line.strip(), current_version_tuple) 27 | if res: 28 | return f'''This project requires a different oTree version. Enter: pip3 install "{line}"''' 29 | 30 | 31 | def check_update_needed_line(line, current_version: tuple): 32 | # simpler if we don't have to deal with any extra content on the line, 33 | # such as a comment that might contain a version number etc. 34 | 35 | def check(rhs_fmt): 36 | lhs = 'otree(\[mturk\])?' 37 | rhs = rhs_fmt.format(VERSION='([\d\.]+)') 38 | match = re.match(lhs + rhs, line) 39 | if match: 40 | groups = match.groups()[1:] 41 | try: 42 | return [split_dotted_version(g) for g in groups] 43 | except ValueError: 44 | pass 45 | 46 | match = check('=={VERSION}') 47 | if match: 48 | [version] = match 49 | if current_version != version: 50 | return True 51 | 52 | match = check('>={VERSION}') 53 | if match: 54 | [version] = match 55 | if current_version < version: 56 | return True 57 | 58 | match = check('>={VERSION},<{VERSION}') 59 | if match: 60 | [version, too_high_version] = match 61 | if current_version < version or current_version >= too_high_version: 62 | return True 63 | 64 | return False 65 | -------------------------------------------------------------------------------- /otree/cli/startapp.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from importlib import import_module 3 | from pathlib import Path 4 | from sys import exit as sys_exit 5 | 6 | import otree 7 | from .base import BaseCommand 8 | from otree.common import app_name_validity_message 9 | 10 | print_function = print 11 | 12 | 13 | class Command(BaseCommand): 14 | def add_arguments(self, parser): 15 | parser.add_argument('name') 16 | 17 | def handle(self, name): 18 | dest = Path(name) 19 | if dest.exists(): 20 | sys_exit( 21 | f'There is already an app called "{name}" ' 22 | 'in this folder. Either delete that folder first, or use a different name.' 23 | ) 24 | try: 25 | import_module(name) 26 | except ModuleNotFoundError: 27 | pass 28 | else: 29 | sys_exit( 30 | f"'{name}' conflicts with the name of an existing Python " 31 | "module. Please try " 32 | "another name." 33 | ) 34 | msg = app_name_validity_message(name) 35 | if msg: 36 | sys_exit(msg) 37 | 38 | use_noself = False 39 | for p in Path('.').glob('*/__init__.py'): 40 | if 'class Player' in p.read_text('utf8'): 41 | use_noself = True 42 | # if it's an empty project, we should default to noself. 43 | if not list(Path('.').glob('*/models.py')): 44 | use_noself = True 45 | if use_noself: 46 | src = Path(otree.__file__).parent / 'app_template_lite' 47 | shutil.copytree(src, dest) 48 | models_path = dest.joinpath('__init__.py') 49 | else: 50 | src = Path(otree.__file__).parent / 'app_template' 51 | shutil.copytree(src, dest) 52 | dest.joinpath('templates/app_name').rename( 53 | dest.joinpath('templates/', name) 54 | ) 55 | models_path = dest.joinpath('models.py') 56 | models_path.write_text(models_path.read_text().replace("{{ app_name }}", name)) 57 | print_function('Created app folder') 58 | -------------------------------------------------------------------------------- /otree/models_concrete.py: -------------------------------------------------------------------------------- 1 | import time 2 | from collections import defaultdict 3 | from typing import Iterable 4 | from otree.database import AnyModel, db, MixinSessionFK 5 | from sqlalchemy.orm import relationship 6 | from sqlalchemy import Column, ForeignKey 7 | from sqlalchemy.sql import sqltypes as st 8 | 9 | import json 10 | 11 | 12 | class PageTimeBatch(AnyModel): 13 | text = Column(st.Text) 14 | 15 | 16 | class CompletedGroupWaitPage(AnyModel, MixinSessionFK): 17 | 18 | page_index = Column(st.Integer) 19 | group_id = Column(st.Integer) 20 | 21 | 22 | class CompletedGBATWaitPage(AnyModel, MixinSessionFK): 23 | 24 | page_index = Column(st.Integer) 25 | id_in_subsession = Column(st.Integer, default=0) 26 | 27 | 28 | class CompletedSubsessionWaitPage(AnyModel, MixinSessionFK): 29 | 30 | page_index = Column(st.Integer) 31 | 32 | 33 | class ParticipantVarsFromREST(AnyModel): 34 | 35 | participant_label = Column(st.String(255)) 36 | room_name = Column(st.String(255)) 37 | _json_data = Column(st.Text) 38 | 39 | @property 40 | def vars(self): 41 | return json.loads(self._json_data) 42 | 43 | @vars.setter 44 | def vars(self, value): 45 | self._json_data = json.dumps(value) 46 | 47 | 48 | class RoomToSession(AnyModel, MixinSessionFK): 49 | 50 | room_name = Column(st.String(255), unique=True) 51 | 52 | 53 | class ChatMessage(AnyModel): 54 | 55 | # the name "channel" here is unrelated to Django channels 56 | channel = Column(st.String(255)) 57 | participant_id = Column(st.Integer, ForeignKey('otree_participant.id', ondelete='CASCADE')) 58 | participant = relationship("Participant") 59 | nickname = Column(st.String(255)) 60 | 61 | # call it 'body' instead of 'message' or 'content' because those terms 62 | # are already used by channels 63 | body = Column(st.Text) 64 | timestamp = Column(st.Float, default=time.time) 65 | 66 | 67 | class TaskQueueMessage(AnyModel): 68 | 69 | method = Column(st.String(50)) 70 | kwargs_json = Column(st.Text) 71 | epoch_time = Column(st.Integer) 72 | 73 | def kwargs(self) -> dict: 74 | return json.loads(self.kwargs_json) 75 | -------------------------------------------------------------------------------- /otree/cli/resetdb.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import logging 3 | 4 | from sqlalchemy import MetaData 5 | 6 | from .base import BaseCommand 7 | from otree.database import engine, AnyModel 8 | from sqlalchemy.orm import close_all_sessions 9 | 10 | logger = logging.getLogger('otree') 11 | 12 | MSG_RESETDB_SUCCESS_FOR_HUB = 'Created new tables and columns.' 13 | MSG_DB_ENGINE_FOR_HUB = 'Database engine' 14 | 15 | print_function = print 16 | 17 | 18 | class Command(BaseCommand): 19 | help = ( 20 | "Resets your development database to a fresh state. " 21 | "All data will be deleted." 22 | ) 23 | 24 | def add_arguments(self, parser): 25 | ahelp = ( 26 | 'Tells the resetdb command to NOT prompt the user for ' 'input of any kind.' 27 | ) 28 | parser.add_argument( 29 | '--noinput', 30 | action='store_false', 31 | dest='interactive', 32 | default=True, 33 | help=ahelp, 34 | ) 35 | 36 | def _confirm(self) -> bool: 37 | print_function("This will delete and recreate your database. ") 38 | answer = input("Proceed? (y or n): ") 39 | if answer: 40 | return answer[0].lower() == 'y' 41 | return False 42 | 43 | def handle(self, *, interactive, **options): 44 | if interactive and not self._confirm(): 45 | print_function('Canceled.') 46 | return 47 | 48 | # hub depends on this string 49 | logger.info(f"{MSG_DB_ENGINE_FOR_HUB}: {engine.name}") 50 | 51 | with contextlib.closing(engine.connect()) as bind: 52 | trans = bind.begin() 53 | old_meta = MetaData() 54 | 55 | # these will hang if any other processes have a long-running DB connection 56 | old_meta.reflect(bind) 57 | old_meta.drop_all(bind) 58 | 59 | # tables get automatically created during init_orm() 60 | # but since we just deleted tables, we need to re-create 61 | AnyModel.metadata.create_all(bind) 62 | trans.commit() 63 | # oTree Hub depends on this string 64 | logger.info(MSG_RESETDB_SUCCESS_FOR_HUB) 65 | -------------------------------------------------------------------------------- /otree/cli/devserver_inner.py: -------------------------------------------------------------------------------- 1 | from otree.checks import run_checks 2 | from .base import BaseCommand 3 | from .prodserver1of2 import get_addr_port, run_asgi_server 4 | from ..database import save_sqlite_db, DB_FILE 5 | 6 | print_function = print 7 | 8 | 9 | ADVICE_DELETE_DB = f'ADVICE: Delete your database ({DB_FILE}).' 10 | 11 | 12 | class Command(BaseCommand): 13 | def add_arguments(self, parser): 14 | 15 | # see log_action below; we only show logs of each request 16 | # if verbosity >= 1. 17 | # this still allows logger.info and logger.warning to be shown. 18 | # NOTE: if we change this back to 1, then need to update devserver 19 | # not to show traceback of errors. 20 | parser.set_defaults(verbosity=0) 21 | 22 | parser.add_argument( 23 | 'addrport', nargs='?', help='Optional port number, or ipaddr:port' 24 | ) 25 | 26 | parser.add_argument( 27 | '--is-reload', action='store_true', dest='is_reload', default=False, 28 | ) 29 | 30 | parser.add_argument( 31 | '--is-zipserver', action='store_true', dest='is_zipserver', default=False, 32 | ) 33 | 34 | def handle(self, *args, addrport, is_reload, is_zipserver, **options): 35 | self.is_zipserver = is_zipserver 36 | 37 | addr, port = get_addr_port(addrport, is_devserver=True) 38 | if not is_reload: 39 | run_checks() 40 | # 0.0.0.0 is not a regular IP address, so we can't tell the user 41 | # to open their browser to that address 42 | if addr == '127.0.0.1': 43 | addr_readable = 'localhost' 44 | elif addr == '0.0.0.0': 45 | addr_readable = '' 46 | else: 47 | addr_readable = addr 48 | print_function( 49 | ( 50 | f"Open your browser to http://{addr_readable}:{port}/\n" 51 | "To quit the server, press Control+C.\n" 52 | ) 53 | ) 54 | 55 | try: 56 | run_asgi_server(addr, port, is_devserver=True) 57 | except KeyboardInterrupt: 58 | return 59 | finally: 60 | save_sqlite_db() 61 | -------------------------------------------------------------------------------- /otree/static/otree/css/theme.css: -------------------------------------------------------------------------------- 1 | /************************* 2 | * FORMS * 3 | *************************/ 4 | 5 | /* Checkbox and radio button lists shall not have bullet points for every 6 | item. */ 7 | .controls > ul { 8 | list-style-type: none; 9 | padding-left: 2em; 10 | } 11 | 12 | /* Input fields should only take up to 400px width and not stretch over the 13 | whole screen */ 14 | .input-group, 15 | input.form-control, 16 | select.form-control { 17 | max-width: 400px; 18 | } 19 | 20 | .input-group-narrow { 21 | /* Choosing a very small value that will always be filled with the input 22 | * type and input-group-addon. That way we don't allow a gap between the 23 | * two. See: https://github.com/oTree-org/otree-core/issues/257 24 | */ 25 | max-width: 200px; 26 | } 27 | 28 | /* Dropdowns shall occupy only the needed space. */ 29 | select.form-select { 30 | width: auto; 31 | } 32 | 33 | .form-control-errors { 34 | margin-top: 5px; 35 | color: #b94a48; 36 | } 37 | 38 | /* Hide those input spinners on webkit and mozilla browsers */ 39 | input[type=number]::-webkit-inner-spin-button, 40 | input[type=number]::-webkit-outer-spin-button { -webkit-appearance : none; margin : 0; } 41 | input[type=number] { -moz-appearance : textfield; } 42 | 43 | /* Slider Widget */ 44 | .slider [data-slider-value] { 45 | min-width: 5em; 46 | } 47 | .asteriskField { 48 | display: none; 49 | } 50 | 51 | .radio { 52 | margin-top:0px; 53 | } 54 | .help-block { 55 | margin-top:5px; 56 | margin-bottom:5px; 57 | } 58 | 59 | .page-header { 60 | margin-top: 20px; 61 | margin-bottom: 20px; 62 | padding-top: 40px; 63 | } 64 | 65 | 66 | /* DEBUG TOOLBAR */ 67 | 68 | .scrollable { 69 | width: 100%; 70 | height: 100%; 71 | margin: 0; 72 | padding: 0; 73 | overflow: auto; 74 | } 75 | 76 | table.table-debug td.debug-var-name { 77 | width: 20% !important; 78 | 79 | } 80 | 81 | table.table-debug td.debug-var-value { 82 | max-width: 0px; 83 | } 84 | 85 | .top-left-fixed-alert { 86 | position: fixed; 87 | top: 0; 88 | left: 0; 89 | background-color: lightgray; 90 | font-style: italic; 91 | visibility: hidden; 92 | /* need to be on top of bs4 navbar which is 1030 */ 93 | z-index: 1100; 94 | } 95 | 96 | 97 | .otree-body { 98 | max-width:970px 99 | } 100 | -------------------------------------------------------------------------------- /otree/cli/upcase_constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | from importlib import import_module 3 | from pathlib import Path 4 | 5 | """Don't import from remove_self, because that requires rope to be installed.""" 6 | from otree.common import get_class_bounds 7 | from otree.constants import BaseConstants 8 | from .base import BaseCommand 9 | 10 | print_function = print 11 | 12 | 13 | class Command(BaseCommand): 14 | def add_arguments(self, parser): 15 | parser.add_argument('apps', nargs='*') 16 | parser.add_argument( 17 | '--keep', action='store_true', dest='keep_old_files', default=False, 18 | ) 19 | 20 | def handle(self, *args, apps, keep_old_files, **options): 21 | root = Path('.') 22 | for app in root.iterdir(): 23 | init_path = app.joinpath('__init__.py') 24 | app_name = app.name 25 | if not init_path.exists(): 26 | continue 27 | text = init_path.read_text('utf8') 28 | if 'class Constants(' not in text: 29 | print_function(f"Skipping {app_name}") 30 | continue 31 | text = text.replace('class Constants(', 'class C(') 32 | module = import_module(app_name) 33 | Constants = module.Constants 34 | classvars = [k for k in vars(Constants) if k not in vars(BaseConstants)] 35 | cls_start, cls_end = get_class_bounds(text, 'C') 36 | class_txt = text[cls_start:cls_end] 37 | for classvar in classvars: 38 | class_txt = re.sub( 39 | r'\b' + classvar + r'\b', classvar.upper(), class_txt 40 | ) 41 | final = text[:cls_start] + class_txt + text[cls_end:] 42 | init_path.write_text(final, encoding='utf8') 43 | 44 | files_to_replace = [] 45 | # need to replace all files and templates, including in _templates folder. 46 | for glob in ['*.html', '*.py', '*/*.html', '*/*.py', '*/*/*.html', '*/*/*.py']: 47 | files_to_replace.extend(root.glob(glob)) 48 | 49 | for p in files_to_replace: 50 | txt = p.read_text('utf8') 51 | # somehow \w also works with non-latin chars, nice 52 | txt = re.sub(r'\bConstants\.(\w+)\b', upcase, txt) 53 | p.write_text(txt, encoding='utf8') 54 | print_function("Done") 55 | 56 | 57 | def upcase(match): 58 | upcased_name = match.group(1).upper() 59 | return f'C.{upcased_name}' 60 | -------------------------------------------------------------------------------- /otree/templating/filters.py: -------------------------------------------------------------------------------- 1 | import html 2 | from otree.i18n import core_gettext 3 | from otree import common 4 | from otree.currency import Currency, json_dumps, BaseCurrency 5 | from otree.i18n import format_number 6 | 7 | # Dictionary of registered filter functions. 8 | filtermap = {} 9 | 10 | 11 | # Decorator function for registering filters. A filter function should accept at least one 12 | # argument - the value to be filtered - and return the filtered result. It can optionally 13 | # accept any number of additional arguments. 14 | # 15 | # This decorator can be used as: 16 | # 17 | # @register 18 | # @register() 19 | # @register('name') 20 | # 21 | # If no name is supplied the function name will be used. 22 | def register(nameorfunc=None): 23 | 24 | if callable(nameorfunc): 25 | filtermap[nameorfunc.__name__] = nameorfunc 26 | return nameorfunc 27 | 28 | def register_filter_function(func): 29 | filtermap[nameorfunc or func.__name__] = func 30 | return func 31 | 32 | return register_filter_function 33 | 34 | 35 | @register 36 | def default(obj, fallback): 37 | """ Returns `obj` if `obj` is truthy, otherwise `fallback`. """ 38 | return obj or fallback 39 | 40 | 41 | @register 42 | def escape(s, quotes=True): 43 | """ Converts html syntax characters to character entities. """ 44 | return html.escape(s, quotes) 45 | 46 | 47 | @register 48 | def length(seq): 49 | """ Returns the length of the sequence `seq`. """ 50 | return len(seq) 51 | 52 | 53 | @register('c') 54 | @register('cu') 55 | def currency_filter(val): 56 | return Currency(val) 57 | 58 | 59 | @register 60 | def safe(val): 61 | return val 62 | 63 | 64 | @register 65 | def gettext(val): 66 | return core_gettext(val) 67 | 68 | 69 | @register 70 | def json(val): 71 | return json_dumps(val) 72 | 73 | 74 | def to_places(val, places): 75 | if isinstance(val, BaseCurrency): 76 | return val._format_currency(places=places) 77 | return format_number(val, places=places) 78 | 79 | 80 | @register 81 | def to0(val): 82 | return to_places(val, 0) 83 | 84 | 85 | @register 86 | def to1(val): 87 | return to_places(val, 1) 88 | 89 | 90 | @register 91 | def to2(val): 92 | return to_places(val, 2) 93 | 94 | 95 | @register 96 | def linebreaks(val): 97 | """|linebreaks was used in an old sample games. 98 | this is just a shim.""" 99 | return val 100 | -------------------------------------------------------------------------------- /otree/lookup.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from functools import lru_cache 3 | from typing import Dict 4 | 5 | from otree.common import get_pages_module, get_models_module, get_constants 6 | from otree.database import dbq 7 | from otree.models import Session 8 | 9 | PageLookup = namedtuple( 10 | 'PageInfo', 11 | [ 12 | 'app_name', 13 | 'page_class', 14 | 'round_number', 15 | 'subsession_id', 16 | 'name_in_url', 17 | 'session_pk', 18 | ], 19 | ) 20 | 21 | 22 | @lru_cache(maxsize=32) 23 | def _get_session_lookups(session_code) -> Dict[int, PageLookup]: 24 | session = dbq(Session).filter_by(code=session_code).one() 25 | pages = {} 26 | idx = 1 27 | for app_name in session.config['app_sequence']: 28 | models = get_models_module(app_name) 29 | Subsession = models.Subsession 30 | page_sequence = get_pages_module(app_name).page_sequence 31 | subsessions = { 32 | s[0]: s[1] 33 | for s in Subsession.objects_filter(session=session).with_entities( 34 | Subsession.round_number, Subsession.id 35 | ) 36 | } 37 | 38 | Constants = get_constants(app_name) 39 | num_rounds = Constants.get_normalized('num_rounds') 40 | name_in_url = Constants.get_normalized('name_in_url') 41 | for rd in range(1, num_rounds + 1): 42 | for PageClass in page_sequence: 43 | pages[idx] = PageLookup( 44 | app_name=app_name, 45 | page_class=PageClass, 46 | round_number=rd, 47 | subsession_id=subsessions[rd], 48 | session_pk=session.id, 49 | name_in_url=name_in_url, 50 | ) 51 | idx += 1 52 | return pages 53 | 54 | 55 | def get_page_lookup(session_code, idx) -> PageLookup: 56 | cache = _get_session_lookups(session_code) 57 | return cache[idx] 58 | 59 | 60 | def get_min_idx_for_app(session_code, app_name): 61 | '''for aatp''' 62 | for idx, info in _get_session_lookups(session_code).items(): 63 | if info.app_name == app_name: 64 | return idx 65 | 66 | 67 | def url_i_should_be_on(participant_code, session_code, index_in_pages) -> str: 68 | idx = index_in_pages 69 | lookup = get_page_lookup(session_code, idx) 70 | return lookup.page_class.get_url( 71 | participant_code=participant_code, 72 | name_in_url=lookup.name_in_url, 73 | page_index=idx, 74 | ) 75 | -------------------------------------------------------------------------------- /otree/templates/otree/Page.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/Base.html" %} 2 | 3 | 4 | {% comment %} 5 | NOTE: 6 | we should keep this page as simple as possible so that 'view source' is friendly 7 | i removed many linebreaks to make output HTML cleaner 8 | {% endcomment %} 9 | {% block body_main %} 10 |
11 | 12 | {% if view.remaining_timeout_seconds() != None %} 13 | {% with form_element_id="form" %} 14 | {% include 'otree/includes/TimeLimit.html' %} 15 | {% endwith %} 16 | {% endif %} 17 | {% if form.errors %} 18 |
19 | {% if form.non_field_error %} 20 | {{ form.non_field_error }} 21 | {% else %} 22 | {{ "Please fix the errors."|gettext }} 23 | {% endif %} 24 |
25 | {% endif %}{% if is_defined('js_vars') and js_vars %}{% endif %} 26 |
27 | 28 |
29 | 30 | {% block content %}{% endblock %} 31 | 32 |
33 |
34 |
35 | {# need the check for projects with old MTurkLandingPage #} 36 | {% if is_defined('view.is_debug') and view.is_debug %} 37 |
38 | {% include 'otree/includes/debug_info.html' %} 39 | {% endif %} 40 |
41 | {% endblock %} 42 | {% block internal_scripts %} 43 | 44 | {{ super() }} 45 | 49 | {% if view.remaining_timeout_seconds() != None %} 50 | {% include 'otree/includes/TimeLimit.js.html' %} 51 | {% endif %} 52 | {% endblock %} 53 | 54 | {% block live %} 55 | {% if has_live_method %} 56 |
57 | 58 | {% endif %} 59 | {% endblock %} -------------------------------------------------------------------------------- /otree/chat.py: -------------------------------------------------------------------------------- 1 | import re 2 | from otree.common import signer_sign, signer_unsign 3 | 4 | from otree.channels import utils as channel_utils 5 | from otree.i18n import core_gettext 6 | 7 | 8 | class ChatTagError(Exception): 9 | pass 10 | 11 | 12 | class UNDEFINED: 13 | pass 14 | 15 | 16 | def chat_template_tag(context, *, channel=UNDEFINED, nickname=UNDEFINED) -> dict: 17 | player = context['player'] 18 | group = context['group'] 19 | Constants = context.get('C') or context['Constants'] 20 | participant = context['participant'] 21 | 22 | if channel == UNDEFINED: 23 | channel = group.id 24 | channel = str(channel) 25 | # channel name should not contain illegal chars, 26 | # so that it can be used in JS and URLs 27 | if not re.match(r'^[a-zA-Z0-9_-]+$', channel): 28 | msg = ( 29 | "'channel' can only contain ASCII letters, numbers, underscores, and hyphens. " 30 | "Value given was: {}".format(channel) 31 | ) 32 | raise ChatTagError(msg) 33 | # prefix the channel name with session code and app name 34 | prefixed_channel = '{}-{}-{}'.format( 35 | context['session'].id, 36 | Constants.get_normalized('name_in_url'), 37 | # previously used a hash() here to ensure name_in_url is the same, 38 | # but hash() is non-reproducible across processes 39 | channel, 40 | ) 41 | context['channel'] = prefixed_channel 42 | 43 | if nickname == UNDEFINED: 44 | # Translators: A player's default chat nickname, 45 | # which is "Player" + their ID in group. For example: 46 | # "Player 2". 47 | nickname = core_gettext('Participant {id_in_group}').format( 48 | id_in_group=player.id_in_group 49 | ) 50 | nickname = str(nickname) 51 | nickname_signed = signer_sign(nickname) 52 | 53 | socket_path = channel_utils.chat_path(prefixed_channel, participant.id) 54 | 55 | chat_vars_for_js = dict( 56 | channel=prefixed_channel, 57 | socket_path=socket_path, 58 | participant_id=participant.id, 59 | nickname_signed=nickname_signed, 60 | nickname=nickname, 61 | # Translators: the name you see in chat for yourself, for example: 62 | # John (Me) 63 | nickname_i_see_for_myself=core_gettext("{nickname} (Me)").format( 64 | nickname=nickname 65 | ), 66 | ) 67 | return dict( 68 | channel=prefixed_channel, 69 | # send this as one item so it can be json dumped & loaded into js 70 | # in one line. 71 | chat_vars_for_js=chat_vars_for_js, 72 | ) 73 | -------------------------------------------------------------------------------- /otree/templates/otree/SessionMonitor.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/Session.html" %} 2 | 3 | 4 | {% block content %} 5 | {{ super() }} 6 | 8 | 9 | 10 | {% for header in column_names %} 11 | 12 | {% endfor %} 13 | 14 | 15 | 16 |
{{ header }}
17 | 18 |

0/{{ session.num_participants }} participants started.

19 |

20 | 21 | {% if not session.use_browser_bots %} 22 | {% csrf_token %} 23 | 31 | {% endif %} 32 | 33 | 37 | 38 | 43 | 44 | {% endblock %} 45 | 46 | 47 | {% block internal_scripts %} 48 | {{ super() }} 49 | 58 | 59 | 60 | 61 | 72 | {% endblock %} 73 | -------------------------------------------------------------------------------- /otree/settings.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import sys 3 | 4 | 5 | DEBUG = os.environ.get('OTREE_PRODUCTION') in [None, '', '0'] 6 | AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID') 7 | AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY') 8 | AUTH_LEVEL = os.environ.get('OTREE_AUTH_LEVEL') 9 | REAL_WORLD_CURRENCY_CODE = 'USD' 10 | USE_POINTS = True 11 | POINTS_DECIMAL_PLACES = 0 12 | POINTS_CUSTOM_NAME = None # define it so we can patch it 13 | ADMIN_PASSWORD = os.environ.get('OTREE_ADMIN_PASSWORD', '') 14 | MTURK_NUM_PARTICIPANTS_MULTIPLE = 2 15 | BOTS_CHECK_HTML = True 16 | PARTICIPANT_FIELDS = [] 17 | SESSION_FIELDS = [] 18 | 19 | # Add the current directory to sys.path so that Python can find 20 | # the settings module. 21 | # when using "python manage.py" this is not necessary because 22 | # the entry-point script's dir is automatically added to sys.path. 23 | # but the 'otree' command script is located outside of the project 24 | # directory. 25 | if os.getcwd() not in sys.path: 26 | sys.path.insert(0, os.getcwd()) 27 | 28 | 29 | try: 30 | import settings 31 | from settings import * 32 | except ModuleNotFoundError as exc: 33 | if exc.name == 'settings': 34 | msg = ( 35 | "Cannot find oTree settings. " 36 | "Please 'cd' to your oTree project folder, " 37 | "which contains a settings.py file." 38 | ) 39 | sys.exit(msg) 40 | raise 41 | 42 | 43 | def get_OTREE_APPS(SESSION_CONFIGS): 44 | from itertools import chain 45 | 46 | app_sequences = [s['app_sequence'] for s in SESSION_CONFIGS] 47 | return list(dict.fromkeys(chain(*app_sequences))) 48 | 49 | 50 | OTREE_APPS = get_OTREE_APPS(settings.SESSION_CONFIGS) 51 | if not hasattr(settings, 'REAL_WORLD_CURRENCY_DECIMAL_PLACES'): 52 | if REAL_WORLD_CURRENCY_CODE in [ 53 | 'KRW', 54 | 'JPY', 55 | 'HUF', 56 | 'IRR', 57 | 'COP', 58 | 'VND', 59 | 'IDR', 60 | ]: 61 | REAL_WORLD_CURRENCY_DECIMAL_PLACES = 0 62 | else: 63 | REAL_WORLD_CURRENCY_DECIMAL_PLACES = 2 64 | 65 | 66 | def get_locale_name(language_code): 67 | if language_code == 'zh-hans': 68 | return 'zh_Hans' 69 | parts = language_code.split('-') 70 | if len(parts) == 2: 71 | return parts[0] + '_' + parts[1].upper() 72 | return language_code 73 | 74 | 75 | LANGUAGE_CODE_ISO = get_locale_name(LANGUAGE_CODE) 76 | 77 | 78 | def get_decimal_separator(lc): 79 | 80 | if lc in ['en', 'ja', 'ko', 'ms', 'th', 'zh']: 81 | return '.' 82 | else: 83 | return ',' 84 | 85 | 86 | DECIMAL_SEPARATOR = get_decimal_separator(LANGUAGE_CODE[:2]) 87 | -------------------------------------------------------------------------------- /otree/templates/otree/Session.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/BaseAdmin.html" %} 2 | 3 | 4 | {% block title %} 5 | {{ session.config.display_name }}: session '{{ session.code }}'{% if session.is_demo %} (demo) {% endif %} 6 | {% endblock %} 7 | 8 | {% block menus %} 9 | 60 | 61 | 66 | 67 | {% endblock %} 68 | -------------------------------------------------------------------------------- /otree/constants.py: -------------------------------------------------------------------------------- 1 | from otree.i18n import core_gettext 2 | 3 | 4 | class MustCopyError(Exception): 5 | pass 6 | 7 | 8 | def _raise_must_copy(*args, **kwargs): 9 | msg = ( 10 | "Cannot modify a list that originated in your constants class. " 11 | "First, you must make a copy of it, e.g. mylist.copy() " 12 | "This is to prevent accidentally modifying the original list. " 13 | ) 14 | raise MustCopyError(msg) 15 | 16 | 17 | class ConstantsList(list): 18 | 19 | __setitem__ = _raise_must_copy 20 | __delitem__ = _raise_must_copy 21 | clear = _raise_must_copy 22 | __iadd__ = _raise_must_copy 23 | __imul__ = _raise_must_copy 24 | append = _raise_must_copy 25 | extend = _raise_must_copy 26 | insert = _raise_must_copy 27 | pop = _raise_must_copy 28 | remove = _raise_must_copy 29 | reverse = _raise_must_copy 30 | sort = _raise_must_copy 31 | 32 | 33 | class BaseConstantsMeta(type): 34 | def __setattr__(cls, attr, value): 35 | raise AttributeError("Constants are read-only.") 36 | 37 | def __new__(mcs, name, bases, attrs): 38 | 39 | for k, v in attrs.items(): 40 | if type(v) == list: 41 | attrs[k] = ConstantsList(v) 42 | 43 | return super().__new__(mcs, name, bases, attrs) 44 | 45 | 46 | class BaseConstants(metaclass=BaseConstantsMeta): 47 | @classmethod 48 | def get_normalized(cls, attr): 49 | if cls.__name__ == 'C': 50 | return getattr(cls, attr.upper()) 51 | return getattr(cls, attr) 52 | 53 | 54 | def get_roles(Constants) -> list: 55 | roles = [] 56 | for k, v in Constants.__dict__.items(): 57 | if k.upper().endswith('_ROLE') or k.upper().startswith('ROLE_'): 58 | if not isinstance(v, str): 59 | # this is especially for legacy apps before the role_* feature was introduced. 60 | msg = f"{k}: any Constant that ends with '_role' must be a string, for example: sender_role = 'Sender'" 61 | raise Exception(msg) 62 | roles.append(v) 63 | return roles 64 | 65 | 66 | def get_role(roles, id_in_group): 67 | '''this is split apart from get_roles_ as a perf optimization''' 68 | if roles and len(roles) >= id_in_group: 69 | return roles[id_in_group - 1] 70 | return '' 71 | 72 | 73 | get_param_truth_value = '1' 74 | admin_secret_code = 'admin_secret_code' 75 | timeout_happened = 'timeout_happened' 76 | participant_label = 'participant_label' 77 | wait_page_http_header = 'oTree-Wait-Page' 78 | redisplay_with_errors_http_header = 'oTree-Redisplay-With-Errors' 79 | field_required_msg = core_gettext('This field is required.') 80 | AUTO_NAME_BOTS_EXPORT_FOLDER = 'auto_name' 81 | ADVANCE_SLOWEST_BATCH_SIZE = 20 82 | -------------------------------------------------------------------------------- /otree/views/room.py: -------------------------------------------------------------------------------- 1 | import otree.views.cbv 2 | 3 | from otree.channels import utils as channel_utils 4 | from otree.room import ROOM_DICT, BaseRoom 5 | from otree.session import SESSION_CONFIGS_DICT 6 | from otree.views.admin import CreateSessionForm 7 | from .cbv import AdminView 8 | 9 | 10 | class Rooms(AdminView): 11 | url_pattern = '/rooms' 12 | 13 | def vars_for_template(self): 14 | from threading import get_ident 15 | return {'rooms': ROOM_DICT.values()} 16 | 17 | 18 | class RoomWithoutSession(AdminView): 19 | room: BaseRoom 20 | form_class = CreateSessionForm 21 | 22 | url_pattern = '/room_without_session/{room_name}' 23 | 24 | def intercept_dispatch(self, room_name): 25 | self.room_name = room_name 26 | self.room = ROOM_DICT[room_name] 27 | if self.room.has_session(): 28 | return self.redirect('RoomWithSession', room_name=room_name) 29 | 30 | def get_form(self): 31 | return CreateSessionForm(data=dict(room_name=self.room_name)) 32 | 33 | def get_context_data(self, **kwargs): 34 | return super().get_context_data( 35 | configs=SESSION_CONFIGS_DICT.values(), 36 | participant_urls=self.room.get_participant_urls(self.request), 37 | room_wide_url=self.room.get_room_wide_url(self.request), 38 | room=self.room, 39 | collapse_links=True, 40 | **kwargs 41 | ) 42 | 43 | def socket_url(self): 44 | return channel_utils.room_admin_path(self.room.name) 45 | 46 | 47 | class RoomWithSession(AdminView): 48 | template_name = 'otree/RoomWithSession.html' 49 | room = None 50 | 51 | url_pattern = '/room_with_session/{room_name}' 52 | 53 | def intercept_dispatch(self, room_name): 54 | self.room = ROOM_DICT[room_name] 55 | if not self.room.has_session(): 56 | return self.redirect('RoomWithoutSession', room_name=room_name) 57 | 58 | def get_context_data(self, **kwargs): 59 | from otree.asgi import reverse 60 | 61 | session_code = self.room.get_session().code 62 | return super().get_context_data( 63 | participant_urls=self.room.get_participant_urls(self.request), 64 | room_wide_url=self.room.get_room_wide_url(self.request), 65 | session_url=reverse('SessionMonitor', code=session_code), 66 | room=self.room, 67 | collapse_links=True, 68 | **kwargs 69 | ) 70 | 71 | 72 | class CloseRoom(AdminView): 73 | url_pattern = '/CloseRoom/{room_name}' 74 | 75 | def post(self, request, room_name): 76 | self.room = ROOM_DICT[room_name] 77 | self.room.set_session(None) 78 | return self.redirect('RoomWithoutSession', room_name=room_name) 79 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from setuptools import setup, find_packages 4 | import shutil 5 | from pathlib import Path 6 | 7 | # allow setup.py to be run from any path 8 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 9 | 10 | import otree 11 | 12 | version = otree.__version__ 13 | 14 | SUPPORTED_PY3_VERSIONS = [7, 8, 9, 10, 11, 12, 13] 15 | 16 | # make it visible so it stands out from the rest of the spew 17 | MSG_PY_VERSION = """ 18 | ********************************************************************************** 19 | * Error: This version of oTree is only compatible with these Python versions: 20 | * {} 21 | ********************************************************************************** 22 | """.format( 23 | ', '.join(f'3.{x}' for x in SUPPORTED_PY3_VERSIONS) 24 | ) 25 | 26 | 27 | if sys.version_info[0] != 3 or sys.version_info[1] not in SUPPORTED_PY3_VERSIONS: 28 | sys.exit(MSG_PY_VERSION) 29 | 30 | 31 | def clean_requirements(requirements_text): 32 | required_raw = requirements_text.splitlines() 33 | required = [] 34 | for line in required_raw: 35 | req = line.split('#')[0].strip() 36 | if req: 37 | required.append(req) 38 | return required 39 | 40 | 41 | README = Path('README.rst').read_text('utf8') 42 | required = clean_requirements(Path('requirements.txt').read_text()) 43 | 44 | 45 | if sys.argv[-1] == 'publish': 46 | 47 | if Path('dist').is_dir(): 48 | shutil.rmtree('dist') 49 | for cmd in [ 50 | "python setup.py sdist", 51 | "twine upload dist/*", 52 | f'git tag -a {version} -m "version {version}"', 53 | "git push --tags", 54 | ]: 55 | sys.stdout.write(cmd + '\n') 56 | exit_code = os.system(cmd) 57 | if exit_code != 0: 58 | raise AssertionError 59 | if Path('build').is_dir(): 60 | shutil.rmtree('build') 61 | 62 | sys.exit() 63 | 64 | 65 | setup( 66 | name='otree', 67 | version=version, 68 | include_package_data=True, 69 | license='MIT License', 70 | # 2017-10-03: find_packages function works correctly, but tests 71 | # are still being included in the package. 72 | # not sure why. so instead i use 73 | # recursive-exclude in MANIFEST.in. 74 | packages=find_packages(), 75 | description=('Framework for multiplayer strategy games and complex surveys.'), 76 | long_description=README, 77 | url='http://otree.org/', 78 | author='chris@otree.org', 79 | author_email='chris@otree.org', 80 | install_requires=required, 81 | entry_points={'console_scripts': ['otree=otree.main:execute_from_command_line']}, 82 | zip_safe=False, 83 | # we no longer need boto but people might still have [mturk] in their reqs files 84 | extras_require={'mturk': []}, 85 | ) 86 | -------------------------------------------------------------------------------- /otree/read_csv.py: -------------------------------------------------------------------------------- 1 | from otree.database import st, CurrencyType, Currency 2 | 3 | 4 | def read_csv_bool(val): 5 | if val == '': 6 | return None 7 | return val in ['TRUE', '1', 'True', 'true', 1] 8 | 9 | 10 | def read_csv_int(val): 11 | if val == '': 12 | return None 13 | return int(val) 14 | 15 | 16 | def read_csv_float(val): 17 | if val == '': 18 | return None 19 | return float(val) 20 | 21 | 22 | def read_csv_currency(val): 23 | if val == '': 24 | return None 25 | return Currency(val) 26 | 27 | 28 | def read_csv_str(val): 29 | # should '' be interpreted as empty string or None? 30 | # i think empty string is better. 31 | # (1) the principle that you should not have 2 values for None, 32 | # (2) because this avoids null reference errors. you can use all string operations on an empty string. 33 | # it's true that oTree models use None as the default value for a StringField, 34 | # but that seems a bit different to me. 35 | return str(val) 36 | 37 | 38 | def map_types(d, mapping: dict): 39 | ret = {} 40 | for k, v in d.items(): 41 | type_conversion_function = mapping[k] 42 | try: 43 | ret[k] = type_conversion_function(v) 44 | except Exception: 45 | msg = f"CSV file contains an incompatible value in column '{k}': {repr(v)}" 46 | raise Exception(msg) from None 47 | return ret 48 | 49 | 50 | class MissingFieldError(Exception): 51 | pass 52 | 53 | 54 | def read_csv(path: str, type_model): 55 | import csv 56 | 57 | CONVERSION_FUNCTIONS = { 58 | st.Boolean: read_csv_bool, 59 | CurrencyType: read_csv_currency, 60 | st.Float: read_csv_float, 61 | st.Integer: read_csv_int, 62 | st.String: read_csv_str, 63 | st.Text: read_csv_str, 64 | } 65 | 66 | # even if it's not going into an ExtraModel, you can still make a model just for the purpose of CSV loading. 67 | with open(path, 'r', encoding='utf-8-sig') as f: 68 | reader = csv.DictReader(f) 69 | mapping = {} 70 | for fieldname in reader.fieldnames: 71 | try: 72 | _coltype = type(type_model.__table__.columns[fieldname].type) 73 | except KeyError as exc: 74 | # it's good to be strict and require all columns. This will prevent issues like 75 | # typos and users simply not understanding how the feature works. 76 | model_name = type_model.__name__ 77 | msg = f"CSV file contains column '{exc.args[0]}', which is not found in model {model_name}." 78 | raise MissingFieldError(msg) from None 79 | mapping[fieldname] = CONVERSION_FUNCTIONS[_coltype] 80 | 81 | return [map_types(row, mapping) for row in reader] 82 | -------------------------------------------------------------------------------- /otree/templates/otree/includes/hidden_form_errors.html: -------------------------------------------------------------------------------- 1 | {% if is_defined('view.first_field_with_errors') and view.first_field_with_errors %} 2 | 45 | {% comment %} 46 | formfield:
error
47 | form.errors or form.foo.errors: 48 | {% endcomment %} 49 | 58 | 59 | 60 | {% endif %} -------------------------------------------------------------------------------- /otree/cli/startproject.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import otree 4 | from .base import BaseCommand 5 | import shutil 6 | from pathlib import Path 7 | from tempfile import TemporaryDirectory 8 | from io import BytesIO 9 | import random 10 | 11 | print_function = print 12 | 13 | 14 | def prompt_about_sample_games(): 15 | '''for easy patching''' 16 | return input("Include sample games? (y or n): ") 17 | 18 | 19 | class Command(BaseCommand): 20 | help = "Creates a new oTree project." 21 | 22 | def add_arguments(self, parser): 23 | parser.add_argument('name') 24 | # we need a CLI arg rather than a small function we can patch, 25 | # because our test launches this in a subprocess. 26 | parser.add_argument( 27 | '--noinput', action='store_false', dest='interactive', default=True, 28 | ) 29 | 30 | def handle(self, name, interactive): 31 | dest = Path(name) 32 | if Path('settings.py').exists(): 33 | msg = ( 34 | 'You are trying to create a project but it seems you are ' 35 | 'already in a project folder (found settings.py).' 36 | ) 37 | sys.exit(msg) 38 | if dest.exists(): 39 | msg = ( 40 | f'There is already a project called "{name}" ' 41 | 'in this folder. Either delete that folder first, or use a different name.' 42 | ) 43 | sys.exit(msg) 44 | 45 | if interactive and prompt_about_sample_games().lower() == "y": 46 | download_from_github(dest) 47 | else: 48 | copy_project_template(dest) 49 | settings_path = dest.joinpath('settings.py') 50 | settings_path.write_text( 51 | settings_path.read_text().replace( 52 | "{{ secret_key }}", str(random.randint(10 ** 12, 10 ** 13)) 53 | ) 54 | ) 55 | 56 | msg = ( 57 | 'Created project folder.\n' 58 | f'Enter "cd {name}" to move inside the project folder, ' 59 | 'then start the server with "otree devserver".' # 60 | ) 61 | print_function(msg) 62 | 63 | 64 | def download_from_github(dest: Path): 65 | # expensive import 66 | from urllib.request import urlopen 67 | import zipfile 68 | 69 | branch_name = 'lite' 70 | resp = urlopen(f"https://github.com/oTree-org/oTree/archive/{branch_name}.zip") 71 | f = BytesIO() 72 | f.write(resp.read()) 73 | f.seek(0) 74 | with TemporaryDirectory() as tmpdir: 75 | with zipfile.ZipFile(f, 'r') as zip_ref: 76 | # omit tests.py because it is jarring/distracting with __init__.py format. 77 | zip_ref.extractall( 78 | tmpdir, 79 | members=[f for f in zip_ref.namelist() if not f.endswith('tests.py')], 80 | ) 81 | shutil.move(Path(tmpdir, f'oTree-{branch_name}'), dest) 82 | 83 | 84 | def copy_project_template(dest: Path): 85 | src = Path(otree.__file__).parent / 'project_template' 86 | shutil.copytree(src, dest) 87 | -------------------------------------------------------------------------------- /otree/templates/otree/MTurkCreateHIT.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/Session.html" %} 2 | 3 | 4 | {% block content %} 5 | 6 | {{ super() }} 7 | 8 | {% if not mturk_ready %} 9 |

MTurk is currently disabled. 10 | If you want to publish your HIT on MTurk please do the following 11 | steps: 12 |

13 | 14 |
    15 | 16 | {% if not is_usd %} 17 |
  1. 18 | Set your REAL_WORLD_CURRENCY_CODE to USD 19 |
  2. 20 | {% endif %} 21 | 22 | {% if not aws_keys_exist %} 23 |
  3. 24 | Set AWS_ACCESS_KEY_ID and 25 | AWS_SECRET_ACCESS_KEY 26 |
  4. 27 | {% endif %} 28 | 29 |
30 | 31 | {% elif session.mturk_is_expired() %} 32 |

This HIT has expired, so workers can no longer accept assignments.

33 | {% elif session.mturk_HITId %} 34 |

35 | You have published HIT for this session on MTurk 36 | {% if session.mturk_use_sandbox %} 37 | Sandbox 38 | {% endif %} 39 | .

40 |

41 | To look at the HIT as a worker 42 | follow this link. 44 |

45 |
{% csrf_token %} 46 | 47 |
48 |

49 | The above button will expire this HIT early. 50 | You should click this button before deleting the session. 51 | Otherwise, your nonexistent session will still be advertised 52 | on the MTurk website, and MTurk workers will get a "page not found" error. 53 | (However, it is safe to delete the session if all assignments have been 54 | submitted.) 55 |

56 | {% else %} 57 | 58 | 62 | 63 |
{% csrf_token %} 65 | 66 |
67 | 71 |

72 | If this box is checked, your HIT will not be published to the MTurk live site, but rather 73 | to the MTurk Sandbox, so you can test how it will look to MTurk workers. 74 |

75 |
76 | 77 |

78 | When you click the below button, your HIT will be immediately published on MTurk. 79 |

80 | 83 |
84 | {% endif %} 85 | 86 |
87 | {% include "otree/includes/messages.html" %} 88 | {% endblock %} 89 | -------------------------------------------------------------------------------- /otree/asgi.py: -------------------------------------------------------------------------------- 1 | from starlette.applications import Starlette 2 | from starlette.middleware import Middleware 3 | from starlette.responses import HTMLResponse 4 | from starlette.routing import NoMatchFound 5 | 6 | from otree import errorpage 7 | from otree.database import save_sqlite_db 8 | from . import middleware 9 | from . import settings 10 | from .errorpage import OTreeServerErrorMiddleware 11 | from .patch import ExceptionMiddleware 12 | from .urls import routes 13 | 14 | 15 | class OTreeStarlette(Starlette): 16 | def build_middleware_stack(self): 17 | 18 | debug = self.debug 19 | error_handler = None 20 | exception_handlers = {} 21 | 22 | for key, value in self.exception_handlers.items(): 23 | if key in (500, Exception): 24 | error_handler = value 25 | else: 26 | exception_handlers[key] = value 27 | 28 | # By default Starlette puts ServerErrorMiddleware outside of all user middleware, 29 | # but I need to reverse that, because if we roll back the transaction before the error page 30 | # is displayed, it will show incorrect field values for a model instance's __repr__. 31 | middlewares = [ 32 | Middleware(middleware.CommitTransactionMiddleware), 33 | Middleware(OTreeServerErrorMiddleware, handler=error_handler, debug=debug), 34 | Middleware(middleware.PerfMiddleware), 35 | Middleware(middleware.SessionMiddleware, secret_key=middleware._SECRET), 36 | Middleware(ExceptionMiddleware, handlers=exception_handlers, debug=debug), 37 | ] 38 | 39 | app = self.router 40 | for cls, options in reversed(middlewares): 41 | app = cls(app=app, **options) 42 | return app 43 | 44 | 45 | ERR_500 = 500 46 | 47 | 48 | async def server_error(request, exc): 49 | return HTMLResponse(content=HTML_500_PAGE, status_code=ERR_500) 50 | 51 | 52 | app = OTreeStarlette( 53 | debug=settings.DEBUG, 54 | routes=routes, 55 | exception_handlers={ERR_500: server_error}, 56 | on_shutdown=[save_sqlite_db], 57 | ) 58 | 59 | # alias like django reverse() 60 | def reverse(name, **path_params): 61 | try: 62 | return app.url_path_for(name, **path_params) 63 | except NoMatchFound as exc: 64 | raise NoMatchFound(f'{name}, {path_params}') from None 65 | 66 | 67 | ERR_500_EXPLANATION = """ 68 |

69 | For security reasons, the error is not displayed here. 70 | You can view it with one of the below techniques: 71 |

72 | 73 | 78 | """ 79 | 80 | # 500 page should look like the debug 500 page so that people make the connection 81 | HTML_500_PAGE = errorpage.TEMPLATE.format( 82 | styles=errorpage.STYLES, 83 | otree_styles=errorpage.OTREE_STYLES, 84 | tab_title="Application error (500)", 85 | error="", 86 | ibis_html='', 87 | exc_html=ERR_500_EXPLANATION, 88 | js='', 89 | ) 90 | -------------------------------------------------------------------------------- /otree/templates/otree/CreateDemoSession.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/Base.html" %} 2 | 3 | {% block head_title %} 4 | creating_session 5 | {% endblock %} 6 | 7 | {% block internal_styles %} 8 | {{ super() }} 9 | 39 | {% endblock %} 40 | 41 | {% block body_main %} 42 |
43 |
44 |

45 | creating_session 46 |

47 |
48 |

Please wait, creating session.

49 | 50 |
51 |
52 |
53 |
54 |
55 |
56 | 57 | {% endblock body_main %} 58 | 59 | {% block scripts %} 60 | {{ super() }} 61 | 62 | 99 | {% endblock scripts %} -------------------------------------------------------------------------------- /otree/templates/otree/BaseAdmin.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/Base.html" %} 2 | 3 | 4 | 5 | {% block internal_styles %} 6 | {{ super() }} 7 | 8 | 9 | 15 | 16 | {% endblock %} 17 | 18 | {% block internal_scripts %} 19 | {{ super() }} 20 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | {% endblock %} 41 | {% block body_main %} 42 | 64 | 65 |
66 | 69 | {% block menus %}{% endblock %} 70 |
71 | {% block content %}{% endblock %} 72 |
73 |
74 |
75 | {% block no_container_content %}{% endblock %} 76 | {% comment %} 77 | this exists so that if there is an error in oTree's channel consumers, 78 | such as data export or rooms, the user gets some notification rather than it silently 79 | failing. 80 | don't put it in global base template even though it could be useful there too, 81 | because we don't want to pollute "view source" 82 | in user-defined pages 83 | {% endcomment %} 84 | 85 | {% endblock body_main %} 86 | -------------------------------------------------------------------------------- /otree/templating/context.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from . import errors 3 | 4 | 5 | # User-configurable functions and variables available in all contexts. 6 | builtins = { 7 | 'range': range, 8 | } 9 | 10 | 11 | # A wrapper around a stack of dictionaries. 12 | class DataStack: 13 | def __init__(self): 14 | self.stack = [] 15 | 16 | def __getitem__(self, key): 17 | for d in reversed(self.stack): 18 | if key in d: 19 | return d[key] 20 | raise KeyError(key) 21 | 22 | 23 | # A Context object is a wrapper around the user's input data. Its `.resolve()` method contains 24 | # the lookup-logic for resolving dotted variable names. 25 | class Context: 26 | def __init__(self, data_dict, template): 27 | 28 | # Data store of resolvable variable names for the .resolve() method. 29 | self.data = DataStack() 30 | 31 | # Standard builtins. 32 | self.data.stack.append( 33 | {'context': self, 'is_defined': self.is_defined,} 34 | ) 35 | 36 | # User-configurable builtins. 37 | self.data.stack.append(builtins) 38 | 39 | # Instance-specific data. 40 | self.data.stack.append(data_dict) 41 | 42 | # Nodes can store state information here to avoid threading issues. 43 | self.stash = {} 44 | 45 | # This reference gives nodes access to their parent template object. 46 | self.template = template 47 | 48 | def __setitem__(self, key, value): 49 | self.data.stack[-1][key] = value 50 | 51 | def __getitem__(self, key): 52 | return self.data[key] 53 | 54 | def push(self, data=None): 55 | self.data.stack.append(data or {}) 56 | 57 | def pop(self): 58 | self.data.stack.pop() 59 | 60 | def get(self, key, default=None): 61 | for d in reversed(self.data.stack): 62 | if key in d: 63 | return d[key] 64 | return default 65 | 66 | def update(self, data_dict): 67 | self.data.stack[-1].update(data_dict) 68 | 69 | def resolve(self, varstring, token): 70 | words = [] 71 | result = self.data 72 | for word in varstring.split('.'): 73 | words.append(word) 74 | if hasattr(result, word): 75 | result = getattr(result, word) 76 | else: 77 | try: 78 | result = result[word] 79 | except: 80 | try: 81 | result = result[int(word)] 82 | except: 83 | msg = f"Cannot resolve the variable '{'.'.join(words)}'" 84 | raise errors.UndefinedVariable(msg, token) from None 85 | return result 86 | 87 | def is_defined(self, varstring): 88 | current = self.data 89 | for word in varstring.split('.'): 90 | if hasattr(current, word): 91 | current = getattr(current, word) 92 | else: 93 | try: 94 | current = current[word] 95 | except: 96 | try: 97 | current = current[int(word)] 98 | except: 99 | return False 100 | return True 101 | -------------------------------------------------------------------------------- /otree/cli/devserver.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from subprocess import Popen 4 | from time import sleep 5 | import sys 6 | from otree.main import send_termination_notice 7 | from .base import BaseCommand 8 | 9 | print_function = print 10 | 11 | 12 | def get_mtimes(files) -> dict: 13 | mtimes = {} 14 | for p in files: 15 | try: 16 | mtimes[p] = p.stat().st_mtime 17 | except FileNotFoundError: 18 | pass 19 | return mtimes 20 | 21 | 22 | class Command(BaseCommand): 23 | def add_arguments(self, parser): 24 | parser.add_argument('port', nargs='?', default='8000') 25 | 26 | def handle(self, port): 27 | run_reloader(port) 28 | 29 | 30 | _OTREE_CORE_DEV = os.getenv('OTREE_CORE_DEV') 31 | 32 | 33 | def run_reloader(port): 34 | ''' 35 | better to have my own autoreloader so i can easily swap between daphne/hypercorn/uvicorn 36 | ''' 37 | 38 | proc = Popen(['otree', 'devserver_inner', port]) 39 | 40 | root = Path('.') 41 | files_to_watch = list(root.glob('*.py')) + list(root.glob('*/*.py')) 42 | if _OTREE_CORE_DEV: 43 | # this code causes it to get stuck on proc.wait() for some reason 44 | # 2021-09-05: is this why it got stuck? 45 | files_to_watch.extend(Path('c:/otree/nodj/otree').glob('**/*.py')) 46 | mtimes = get_mtimes(files_to_watch) 47 | is_windows_venv = sys.platform.startswith("win") and sys.prefix != sys.base_prefix 48 | try: 49 | while True: 50 | exit_code = proc.poll() 51 | if exit_code is not None: 52 | return exit_code 53 | new_mtimes = get_mtimes(files_to_watch) 54 | changed_file = None 55 | for f in files_to_watch: 56 | if f in new_mtimes and f in mtimes and new_mtimes[f] != mtimes[f]: 57 | changed_file = f 58 | break 59 | if changed_file: 60 | print_function(changed_file, 'changed, restarting') 61 | mtimes = new_mtimes 62 | child_pid = send_termination_notice(port) 63 | 64 | # with Windows + virtualenv, proc.terminate() doesn't work. 65 | # sys.executable is a wrapper 66 | # so the actual process that is binding the port has a different pid. 67 | if is_windows_venv and not child_pid: 68 | for retry_num in [1, 2, 3]: 69 | print_function('Retrying shutdown', retry_num) 70 | sleep(1) 71 | child_pid = send_termination_notice(port) 72 | if child_pid: 73 | break 74 | if not child_pid: 75 | print_function('Failed to shut down') 76 | 77 | # child_pid is not guaranteed to be returned, so we need proc.terminate() 78 | proc.terminate() 79 | if child_pid: 80 | os.kill(child_pid, 9) 81 | proc = Popen(['otree', 'devserver_inner', port, '--is-reload']) 82 | sleep(1) 83 | except KeyboardInterrupt: 84 | # handle KeyboardInterrupt (KBI) so we don't get a traceback to console. 85 | # The KBI is received first by the subprocess and then by the parent process. 86 | # Python's usual behavior is to wait until a subprocess exits before propagating the KBI 87 | # to the parent process. 88 | # but for some reason in this program, I got console output from the subprocess that seemed to come 89 | # after the main process exited. so, we wait. 90 | proc.wait(2) 91 | -------------------------------------------------------------------------------- /otree/templating/loader.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import os 3 | import otree 4 | from otree import settings 5 | from starlette.responses import HTMLResponse 6 | 7 | from .errors import TemplateLoadError 8 | 9 | 10 | class FileLoader: 11 | def __init__(self, *dirs): 12 | self.dirs = dirs 13 | self.cache = {} 14 | 15 | def load(self, filename: str, template_type=None): 16 | if filename in self.cache: 17 | return self.cache[filename] 18 | 19 | template, path = self.load_from_disk(filename, template_type=template_type) 20 | self.cache[filename] = template 21 | return template 22 | 23 | def search_template(self, template_id) -> Path: 24 | for dir in self.dirs: 25 | path = Path(dir, template_id) 26 | if path.exists(): 27 | return path 28 | msg = f"Loader cannot locate the template file '{template_id}'." 29 | raise TemplateLoadError(msg) 30 | 31 | def load_from_disk(self, template_id, template_type) -> tuple: 32 | from .template import Template # todo: resolve circular import 33 | 34 | abspath = self.search_template(template_id) 35 | try: 36 | template_string = abspath.read_text('utf-8') 37 | except OSError as err: 38 | msg = f"FileLoader cannot load the template file '{abspath}'." 39 | raise TemplateLoadError(msg) from err 40 | template = Template(template_string, template_id, template_type=template_type) 41 | return template, abspath 42 | 43 | 44 | class FileReloader(FileLoader): 45 | def load(self, filename: str, template_type=None): 46 | if filename in self.cache: 47 | cached_mtime, cached_path, cached_template = self.cache[filename] 48 | if cached_path.exists() and cached_path.stat().st_mtime == cached_mtime: 49 | return cached_template 50 | template, path = self.load_from_disk(filename, template_type=template_type) 51 | mtime = path.stat().st_mtime 52 | self.cache[filename] = (mtime, path, template) 53 | return template 54 | 55 | 56 | def get_ibis_loader(): 57 | # should it be based on debug? or prodserver vs devserver? 58 | loader_class = FileReloader if os.getenv('USE_TEMPLATE_RELOADER') else FileLoader 59 | 60 | dirs = [ 61 | Path('.'), # for noself 62 | Path('_templates'), 63 | Path(otree.__file__).parent.joinpath('templates'), 64 | ] + [Path(app_name, 'templates') for app_name in settings.OTREE_APPS] 65 | return loader_class(*dirs) 66 | 67 | 68 | ibis_loader = get_ibis_loader() 69 | 70 | 71 | def get_template_name_if_exists(template_names) -> str: 72 | '''return the path of the first template that exists''' 73 | for fname in template_names: 74 | try: 75 | ibis_loader.load(fname) 76 | except TemplateLoadError: 77 | pass 78 | else: 79 | return fname 80 | raise TemplateLoadError(str(template_names)) 81 | 82 | 83 | def render(template_name, context, template_type=None, **extra_context): 84 | return HTMLResponse( 85 | ibis_loader.load(template_name, template_type=template_type).render( 86 | context, **extra_context, strict_mode=True 87 | ) 88 | ) 89 | # i used to modify the traceback to report the original error, 90 | # but actually i think we shouldn't. 91 | # The main case I had in mind was if the user calls a method like 92 | # player.foo(), but it's simpler if they just don't call any complex methods 93 | # to begin with, and just pass variables to the template. 94 | # that way we don't go against thre grain 95 | -------------------------------------------------------------------------------- /otree/locale/django.pot: -------------------------------------------------------------------------------- 1 | # Translations template for PROJECT. 2 | # Copyright (C) 2021 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2021. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PROJECT VERSION\n" 10 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 11 | "POT-Creation-Date: 2021-10-18 23:30+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=utf-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Generated-By: Babel 2.9.0\n" 19 | 20 | #. Translators: A player's default chat nickname, 21 | #. which is "Player" + their ID in group. For example: 22 | #. "Player 2". 23 | #: ../otree/chat.py:47 24 | msgid "Participant {id_in_group}" 25 | msgstr "" 26 | 27 | #. Translators: the name you see in chat for yourself, for example: 28 | #. John (Me) 29 | #: ../otree/chat.py:63 30 | msgid "{nickname} (Me)" 31 | msgstr "" 32 | 33 | #: ../otree/constants.py:79 34 | msgid "This field is required." 35 | msgstr "" 36 | 37 | #. Translators: display a number of points, 38 | #. like "1 point", "2 points", ... 39 | #. See "Plural-Forms" above for pluralization rules 40 | #. in this language. 41 | #. Explanation at http://bit.ly/1IurMu7 42 | #. In most languages, msgstr[0] is singular, 43 | #. and msgstr[1] is plural 44 | #. the {} represents the number; 45 | #. don't forget to include it in your translation 46 | #: ../otree/currency.py:199 47 | msgid "{} point" 48 | msgid_plural "{} points" 49 | msgstr[0] "" 50 | msgstr[1] "" 51 | 52 | #: ../otree/forms/forms.py:178 53 | msgid "Yes" 54 | msgstr "" 55 | 56 | #: ../otree/forms/forms.py:178 57 | msgid "No" 58 | msgstr "" 59 | 60 | #. Translators: the label next to a "points" input field 61 | #: ../otree/forms/widgets.py:75 62 | msgid "points" 63 | msgstr "" 64 | 65 | #: ../otree/templates/otree/Page.html:22 66 | msgid "Please fix the errors." 67 | msgstr "" 68 | 69 | #: ../otree/templates/otree/RoomInputLabel.html:7 70 | msgid "Welcome" 71 | msgstr "" 72 | 73 | #: ../otree/templates/otree/RoomInputLabel.html:13 74 | msgid "This participant label was not found" 75 | msgstr "" 76 | 77 | #: ../otree/templates/otree/RoomInputLabel.html:15 78 | msgid "Please enter your participant label." 79 | msgstr "" 80 | 81 | #: ../otree/templates/otree/WaitPage.html:46 82 | msgid "An error occurred." 83 | msgstr "" 84 | 85 | #: ../otree/templates/otree/tags/chat.html:7 86 | msgid "Send" 87 | msgstr "" 88 | 89 | #. Translators: the text of the 'next' button 90 | #: ../otree/templating/nodes.py:784 91 | msgid "Next" 92 | msgstr "" 93 | 94 | #: ../otree/views/abstract.py:788 95 | msgid "Time left to complete this page:" 96 | msgstr "" 97 | 98 | #. Translators: the default title of a wait page 99 | #: ../otree/views/abstract.py:814 ../otree/views/participant.py:261 100 | msgid "Please wait" 101 | msgstr "" 102 | 103 | #: ../otree/views/abstract.py:1186 104 | msgid "Waiting for the other participants." 105 | msgstr "" 106 | 107 | #: ../otree/views/abstract.py:1188 108 | msgid "Waiting for the other participant." 109 | msgstr "" 110 | 111 | #. Translators: for example this is shown if you create a session for 10 112 | #. participant. The 11th person to click will get this message 113 | #. It means all participant slots have already been used. 114 | #: ../otree/views/participant.py:34 115 | msgid "Session is full." 116 | msgstr "" 117 | 118 | #: ../otree/views/participant.py:262 119 | msgid "Waiting for your session to begin" 120 | msgstr "" 121 | 122 | -------------------------------------------------------------------------------- /otree/templates/otree/SessionSplitScreen.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Split Screen Demo 7 | 8 | 9 | 62 | 63 | 64 | 65 |
···
66 |
67 | {% for participant_url in participant_urls %} 68 | 69 | {% endfor %} 70 |
71 | 72 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /otree/models/player.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, ForeignKey 2 | from sqlalchemy.ext.declarative import declared_attr 3 | from sqlalchemy.orm import relationship 4 | from sqlalchemy.sql import sqltypes as st 5 | 6 | from otree.common import in_round, in_rounds 7 | from otree.database import db, MixinSessionFK, SPGModel, CurrencyType 8 | 9 | 10 | class BasePlayer(SPGModel, MixinSessionFK): 11 | __abstract__ = True 12 | 13 | id_in_group = Column(st.Integer, nullable=True, index=True,) 14 | 15 | # don't modify this directly! Set player.payoff instead 16 | _payoff = Column(CurrencyType, default=0) 17 | 18 | round_number = Column(st.Integer, index=True) 19 | 20 | # make it non-nullable so that we don't raise an error with null. 21 | # the reason i chose to make this different from ordinary StringFields 22 | # is that it's a property. users can't just use .get('role') because 23 | # that will just access ._role. So we would need some special-casing 24 | # in __getattribute__ for role, which is not desirable. 25 | _role = Column(st.String, nullable=False, default='') 26 | 27 | # as a property, that means it's overridable 28 | @property 29 | def role(self): 30 | return self._role 31 | 32 | @property 33 | def payoff(self): 34 | return self._payoff 35 | 36 | @payoff.setter 37 | def payoff(self, value): 38 | if value is None: 39 | value = 0 40 | delta = value - self._payoff 41 | self._payoff += delta 42 | self.participant.payoff += delta 43 | # should save it because it may not be obvious that modifying 44 | # player.payoff also changes a field on a different model 45 | db.commit() 46 | 47 | @property 48 | def id_in_subsession(self): 49 | return self.participant.id_in_session 50 | 51 | def in_round(self, round_number): 52 | return in_round(type(self), round_number, participant=self.participant) 53 | 54 | def in_rounds(self, first, last): 55 | return in_rounds(type(self), first, last, participant=self.participant) 56 | 57 | def in_previous_rounds(self): 58 | return self.in_rounds(1, self.round_number - 1) 59 | 60 | def in_all_rounds(self): 61 | '''i do it this way because it doesn't rely on idmap''' 62 | return self.in_previous_rounds() + [self] 63 | 64 | def get_others_in_group(self): 65 | return [p for p in self.group.get_players() if p != self] 66 | 67 | def get_others_in_subsession(self): 68 | return [p for p in self.subsession.get_players() if p != self] 69 | 70 | def start(self): 71 | pass 72 | 73 | @declared_attr 74 | def subsession_id(cls): 75 | app_name = cls.get_folder_name() 76 | return Column( 77 | st.Integer, ForeignKey(f'{app_name}_subsession.id', ondelete='CASCADE') 78 | ) 79 | 80 | @declared_attr 81 | def subsession(cls): 82 | return relationship(f'{cls.__module__}.Subsession', back_populates='player_set') 83 | 84 | @declared_attr 85 | def group_id(cls): 86 | app_name = cls.get_folder_name() 87 | # needs to be nullable so re-grouping can happen 88 | return Column(st.Integer, ForeignKey(f'{app_name}_group.id'), nullable=True) 89 | 90 | @declared_attr 91 | def group(cls): 92 | return relationship(f'{cls.__module__}.Group', back_populates='player_set') 93 | 94 | @declared_attr 95 | def participant_id(cls): 96 | return Column(st.Integer, ForeignKey('otree_participant.id')) 97 | 98 | @declared_attr 99 | def participant(cls): 100 | return relationship("Participant") 101 | -------------------------------------------------------------------------------- /otree/locale/zh_Hans/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) 2015 THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , 2015. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2021-10-18 23:30+0200\n" 11 | "PO-Revision-Date: 2015-11-03 19:32+0800\n" 12 | "Last-Translator: \n" 13 | "Language: zh\n" 14 | "Language-Team: \n" 15 | "Plural-Forms: nplurals=1; plural=0\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=utf-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Generated-By: Babel 2.9.0\n" 20 | 21 | #. Translators: A player's default chat nickname, 22 | #. which is "Player" + their ID in group. For example: 23 | #. "Player 2". 24 | #: ../otree/chat.py:47 25 | msgid "Participant {id_in_group}" 26 | msgstr "玩家{id_in_group}" 27 | 28 | #. Translators: the name you see in chat for yourself, for example: 29 | #. John (Me) 30 | #: ../otree/chat.py:63 31 | #, python-brace-format 32 | msgid "{nickname} (Me)" 33 | msgstr "{nickname}(我)" 34 | 35 | #: ../otree/constants.py:79 36 | msgid "This field is required." 37 | msgstr "必填字段" 38 | 39 | #. Translators: display a number of points, 40 | #. like "1 point", "2 points", ... 41 | #. See "Plural-Forms" above for pluralization rules 42 | #. in this language. 43 | #. Explanation at http://bit.ly/1IurMu7 44 | #. In most languages, msgstr[0] is singular, 45 | #. and msgstr[1] is plural 46 | #. the {} represents the number; 47 | #. don't forget to include it in your translation 48 | #: ../otree/currency.py:199 49 | msgid "{} point" 50 | msgid_plural "{} points" 51 | msgstr[0] "{}点" 52 | 53 | #: ../otree/forms/forms.py:178 54 | msgid "Yes" 55 | msgstr "是" 56 | 57 | #: ../otree/forms/forms.py:178 58 | msgid "No" 59 | msgstr "否" 60 | 61 | #. Translators: the label next to a "points" input field 62 | #: ../otree/forms/widgets.py:75 63 | msgid "points" 64 | msgstr "点" 65 | 66 | #: ../otree/templates/otree/Page.html:22 67 | msgid "Please fix the errors." 68 | msgstr "请修复错误。" 69 | 70 | #: ../otree/templates/otree/RoomInputLabel.html:7 71 | msgid "Welcome" 72 | msgstr "欢迎!" 73 | 74 | #: ../otree/templates/otree/RoomInputLabel.html:13 75 | msgid "This participant label was not found" 76 | msgstr "无效输入,请重试。" 77 | 78 | #: ../otree/templates/otree/RoomInputLabel.html:15 79 | msgid "Please enter your participant label." 80 | msgstr "请输入你的参与标签。" 81 | 82 | #: ../otree/templates/otree/WaitPage.html:46 83 | msgid "An error occurred." 84 | msgstr "发生错误。请检查日志或向实验管理员寻求帮助。" 85 | 86 | #: ../otree/templates/otree/tags/chat.html:7 87 | msgid "Send" 88 | msgstr "发送" 89 | 90 | #. Translators: the text of the 'next' button 91 | #: ../otree/templating/nodes.py:784 92 | msgid "Next" 93 | msgstr "下一页" 94 | 95 | #: ../otree/views/abstract.py:788 96 | msgid "Time left to complete this page:" 97 | msgstr "本页面剩余时间" 98 | 99 | #. Translators: the default title of a wait page 100 | #: ../otree/views/abstract.py:814 ../otree/views/participant.py:261 101 | msgid "Please wait" 102 | msgstr "请等待" 103 | 104 | #: ../otree/views/abstract.py:1186 105 | msgid "Waiting for the other participants." 106 | msgstr "请等待其他参与者。" 107 | 108 | #: ../otree/views/abstract.py:1188 109 | msgid "Waiting for the other participant." 110 | msgstr "请等待其他参与者。" 111 | 112 | #. Translators: for example this is shown if you create a session for 10 113 | #. participant. The 11th person to click will get this message 114 | #. It means all participant slots have already been used. 115 | #: ../otree/views/participant.py:34 116 | msgid "Session is full." 117 | msgstr "会话已满。" 118 | 119 | #: ../otree/views/participant.py:262 120 | msgid "Waiting for your session to begin" 121 | msgstr "等待会话开始" 122 | -------------------------------------------------------------------------------- /otree/locale/nb/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) 2016 THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , 2016. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2021-10-18 23:30+0200\n" 11 | "PO-Revision-Date: 2016-05-09 11:30+0200\n" 12 | "Last-Translator: \n" 13 | "Language: nb\n" 14 | "Language-Team: \n" 15 | "Plural-Forms: nplurals=2; plural=(n != 1)\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=utf-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Generated-By: Babel 2.9.0\n" 20 | 21 | #. Translators: A player's default chat nickname, 22 | #. which is "Player" + their ID in group. For example: 23 | #. "Player 2". 24 | #: ../otree/chat.py:47 25 | msgid "Participant {id_in_group}" 26 | msgstr "" 27 | 28 | #. Translators: the name you see in chat for yourself, for example: 29 | #. John (Me) 30 | #: ../otree/chat.py:63 31 | #, python-brace-format 32 | msgid "{nickname} (Me)" 33 | msgstr "" 34 | 35 | #: ../otree/constants.py:79 36 | msgid "This field is required." 37 | msgstr "Dette feltet må fylles ut." 38 | 39 | #. Translators: display a number of points, 40 | #. like "1 point", "2 points", ... 41 | #. See "Plural-Forms" above for pluralization rules 42 | #. in this language. 43 | #. Explanation at http://bit.ly/1IurMu7 44 | #. In most languages, msgstr[0] is singular, 45 | #. and msgstr[1] is plural 46 | #. the {} represents the number; 47 | #. don't forget to include it in your translation 48 | #: ../otree/currency.py:199 49 | msgid "{} point" 50 | msgid_plural "{} points" 51 | msgstr[0] "{} poeng" 52 | msgstr[1] "{} poeng" 53 | 54 | #: ../otree/forms/forms.py:178 55 | msgid "Yes" 56 | msgstr "Ja" 57 | 58 | #: ../otree/forms/forms.py:178 59 | msgid "No" 60 | msgstr "Nei" 61 | 62 | #. Translators: the label next to a "points" input field 63 | #: ../otree/forms/widgets.py:75 64 | msgid "points" 65 | msgstr "poeng" 66 | 67 | #: ../otree/templates/otree/Page.html:22 68 | msgid "Please fix the errors." 69 | msgstr "Vennligst rett feil i skjemaet." 70 | 71 | #: ../otree/templates/otree/RoomInputLabel.html:7 72 | msgid "Welcome" 73 | msgstr "" 74 | 75 | #: ../otree/templates/otree/RoomInputLabel.html:13 76 | msgid "This participant label was not found" 77 | msgstr "" 78 | 79 | #: ../otree/templates/otree/RoomInputLabel.html:15 80 | msgid "Please enter your participant label." 81 | msgstr "" 82 | 83 | #: ../otree/templates/otree/WaitPage.html:46 84 | msgid "An error occurred." 85 | msgstr "En feil inntraff" 86 | 87 | #: ../otree/templates/otree/tags/chat.html:7 88 | msgid "Send" 89 | msgstr "" 90 | 91 | #. Translators: the text of the 'next' button 92 | #: ../otree/templating/nodes.py:784 93 | msgid "Next" 94 | msgstr "Neste" 95 | 96 | #: ../otree/views/abstract.py:788 97 | msgid "Time left to complete this page:" 98 | msgstr "Tid igjen for å fullføre denne siden:" 99 | 100 | #. Translators: the default title of a wait page 101 | #: ../otree/views/abstract.py:814 ../otree/views/participant.py:261 102 | msgid "Please wait" 103 | msgstr "Vennligst vent" 104 | 105 | #: ../otree/views/abstract.py:1186 106 | msgid "Waiting for the other participants." 107 | msgstr "Venter på de andre deltagerne." 108 | 109 | #: ../otree/views/abstract.py:1188 110 | msgid "Waiting for the other participant." 111 | msgstr "Venter på den andre deltageren." 112 | 113 | #. Translators: for example this is shown if you create a session for 10 114 | #. participant. The 11th person to click will get this message 115 | #. It means all participant slots have already been used. 116 | #: ../otree/views/participant.py:34 117 | msgid "Session is full." 118 | msgstr "" 119 | 120 | #: ../otree/views/participant.py:262 121 | msgid "Waiting for your session to begin" 122 | msgstr "" 123 | -------------------------------------------------------------------------------- /otree/locale/ja/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) 2021 THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , 2021. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2021-10-18 23:30+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language: ja\n" 15 | "Language-Team: ja \n" 16 | "Plural-Forms: nplurals=1; plural=0\n" 17 | "MIME-Version: 1.0\n" 18 | "Content-Type: text/plain; charset=utf-8\n" 19 | "Content-Transfer-Encoding: 8bit\n" 20 | "Generated-By: Babel 2.9.0\n" 21 | 22 | #. Translators: A player's default chat nickname, 23 | #. which is "Player" + their ID in group. For example: 24 | #. "Player 2". 25 | #: ../otree/chat.py:47 26 | msgid "Participant {id_in_group}" 27 | msgstr "プレイヤー {id_in_group}" 28 | 29 | #. Translators: the name you see in chat for yourself, for example: 30 | #. John (Me) 31 | #: ../otree/chat.py:63 32 | #, python-brace-format 33 | msgid "{nickname} (Me)" 34 | msgstr "{nickname} (自分)" 35 | 36 | #: ../otree/constants.py:79 37 | msgid "This field is required." 38 | msgstr "この欄に答える必要があります" 39 | 40 | #. Translators: display a number of points, 41 | #. like "1 point", "2 points", ... 42 | #. See "Plural-Forms" above for pluralization rules 43 | #. in this language. 44 | #. Explanation at http://bit.ly/1IurMu7 45 | #. In most languages, msgstr[0] is singular, 46 | #. and msgstr[1] is plural 47 | #. the {} represents the number; 48 | #. don't forget to include it in your translation 49 | #: ../otree/currency.py:199 50 | msgid "{} point" 51 | msgid_plural "{} points" 52 | msgstr[0] "{}ポイント" 53 | 54 | #: ../otree/forms/forms.py:178 55 | msgid "Yes" 56 | msgstr "はい" 57 | 58 | #: ../otree/forms/forms.py:178 59 | msgid "No" 60 | msgstr "いいえ" 61 | 62 | #. Translators: the label next to a "points" input field 63 | #: ../otree/forms/widgets.py:75 64 | msgid "points" 65 | msgstr "ポイント" 66 | 67 | #: ../otree/templates/otree/Page.html:22 68 | msgid "Please fix the errors." 69 | msgstr "誤りを訂正してください" 70 | 71 | #: ../otree/templates/otree/RoomInputLabel.html:7 72 | msgid "Welcome" 73 | msgstr "ようこそ" 74 | 75 | #: ../otree/templates/otree/RoomInputLabel.html:13 76 | msgid "This participant label was not found" 77 | msgstr "正しい値を入力してください" 78 | 79 | #: ../otree/templates/otree/RoomInputLabel.html:15 80 | msgid "Please enter your participant label." 81 | msgstr "参加者番号を入力してください" 82 | 83 | #: ../otree/templates/otree/WaitPage.html:46 84 | msgid "An error occurred." 85 | msgstr "エラーが起きました。履歴を見るか、管理者に質問してください" 86 | 87 | #: ../otree/templates/otree/tags/chat.html:7 88 | msgid "Send" 89 | msgstr "送信" 90 | 91 | #. Translators: the text of the 'next' button 92 | #: ../otree/templating/nodes.py:784 93 | msgid "Next" 94 | msgstr "次へ" 95 | 96 | #: ../otree/views/abstract.py:788 97 | msgid "Time left to complete this page:" 98 | msgstr "このページでの残り時間" 99 | 100 | #. Translators: the default title of a wait page 101 | #: ../otree/views/abstract.py:814 ../otree/views/participant.py:261 102 | msgid "Please wait" 103 | msgstr "しばらくお待ちください" 104 | 105 | #: ../otree/views/abstract.py:1186 106 | msgid "Waiting for the other participants." 107 | msgstr "他の参加者をお待ちください" 108 | 109 | #: ../otree/views/abstract.py:1188 110 | msgid "Waiting for the other participant." 111 | msgstr "他の参加者をお待ちください" 112 | 113 | #. Translators: for example this is shown if you create a session for 10 114 | #. participant. The 11th person to click will get this message 115 | #. It means all participant slots have already been used. 116 | #: ../otree/views/participant.py:34 117 | msgid "Session is full." 118 | msgstr "セッションが満員です" 119 | 120 | #: ../otree/views/participant.py:262 121 | msgid "Waiting for your session to begin" 122 | msgstr "セッションが始まるまでお待ちください" 123 | -------------------------------------------------------------------------------- /otree/locale/he/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # Hebrew translations for PROJECT. 2 | # Copyright (C) 2021 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2021. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PROJECT VERSION\n" 9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 10 | "POT-Creation-Date: 2021-10-18 23:30+0200\n" 11 | "PO-Revision-Date: 2021-10-21 04:23+0200\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language: he\n" 14 | "Language-Team: he \n" 15 | "Plural-Forms: nplurals=2; plural=(n != 1)\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=utf-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Generated-By: Babel 2.9.0\n" 20 | 21 | #. Translators: A player's default chat nickname, 22 | #. which is "Player" + their ID in group. For example: 23 | #. "Player 2". 24 | #: ../otree/chat.py:47 25 | msgid "Participant {id_in_group}" 26 | msgstr "{id_in_group} משתתף" 27 | 28 | #. Translators: the name you see in chat for yourself, for example: 29 | #. John (Me) 30 | #: ../otree/chat.py:63 31 | msgid "{nickname} (Me)" 32 | msgstr "(אני) {nickname}" 33 | 34 | #: ../otree/constants.py:79 35 | msgid "This field is required." 36 | msgstr "זהו שדה חובה" 37 | 38 | #. Translators: display a number of points, 39 | #. like "1 point", "2 points", ... 40 | #. See "Plural-Forms" above for pluralization rules 41 | #. in this language. 42 | #. Explanation at http://bit.ly/1IurMu7 43 | #. In most languages, msgstr[0] is singular, 44 | #. and msgstr[1] is plural 45 | #. the {} represents the number; 46 | #. don't forget to include it in your translation 47 | #: ../otree/currency.py:199 48 | msgid "{} point" 49 | msgid_plural "{} points" 50 | msgstr[0] "נקודה אחת" 51 | msgstr[1] "נקודות {}" 52 | 53 | #: ../otree/forms/forms.py:178 54 | msgid "Yes" 55 | msgstr "כן" 56 | 57 | #: ../otree/forms/forms.py:178 58 | msgid "No" 59 | msgstr "לא" 60 | 61 | #. Translators: the label next to a "points" input field 62 | #: ../otree/forms/widgets.py:75 63 | msgid "points" 64 | msgstr "נקודות" 65 | 66 | #: ../otree/templates/otree/Page.html:22 67 | msgid "Please fix the errors." 68 | msgstr "נא לתקן את השגיאות" 69 | 70 | #: ../otree/templates/otree/RoomInputLabel.html:7 71 | msgid "Welcome" 72 | msgstr "ברוכים הבאים" 73 | 74 | #: ../otree/templates/otree/RoomInputLabel.html:13 75 | msgid "This participant label was not found" 76 | msgstr "תווית משתתף חסרה" 77 | 78 | #: ../otree/templates/otree/RoomInputLabel.html:15 79 | msgid "Please enter your participant label." 80 | msgstr "נא להכניס את תווית המשתתף שלך" 81 | 82 | #: ../otree/templates/otree/WaitPage.html:46 83 | msgid "An error occurred." 84 | msgstr "יש שגיאה" 85 | 86 | #: ../otree/templates/otree/tags/chat.html:7 87 | msgid "Send" 88 | msgstr "שלח" 89 | 90 | #. Translators: the text of the 'next' button 91 | #: ../otree/templating/nodes.py:784 92 | msgid "Next" 93 | msgstr "הבא" 94 | 95 | #: ../otree/views/abstract.py:788 96 | msgid "Time left to complete this page:" 97 | msgstr "זמן נותר להשלמת העמוד" 98 | 99 | #. Translators: the default title of a wait page 100 | #: ../otree/views/abstract.py:814 ../otree/views/participant.py:261 101 | msgid "Please wait" 102 | msgstr "נא להמתין" 103 | 104 | #: ../otree/views/abstract.py:1186 105 | msgid "Waiting for the other participants." 106 | msgstr "ממתינים לשאר המשתתפים" 107 | 108 | #: ../otree/views/abstract.py:1188 109 | msgid "Waiting for the other participant." 110 | msgstr "ממתינים למשתתף הנוסף" 111 | 112 | #. Translators: for example this is shown if you create a session for 10 113 | #. participant. The 11th person to click will get this message 114 | #. It means all participant slots have already been used. 115 | #: ../otree/views/participant.py:34 116 | msgid "Session is full." 117 | msgstr "הסשן מלא" 118 | 119 | #: ../otree/views/participant.py:262 120 | msgid "Waiting for your session to begin" 121 | msgstr "ממתינים לתחילת הסשן" 122 | 123 | -------------------------------------------------------------------------------- /otree/live.py: -------------------------------------------------------------------------------- 1 | import otree.common 2 | from otree.channels import utils as channel_utils 3 | from otree.models import Participant, BasePlayer, BaseGroup 4 | from otree.lookup import get_page_lookup 5 | import logging 6 | from otree.database import NoResultFound 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | async def live_payload_function(participant_code, page_name, payload): 12 | 13 | try: 14 | participant = Participant.objects_get(code=participant_code) 15 | except NoResultFound: 16 | logger.warning(f'Participant not found: {participant_code}') 17 | return 18 | lookup = get_page_lookup(participant._session_code, participant._index_in_pages) 19 | app_name = lookup.app_name 20 | models_module = otree.common.get_models_module(app_name) 21 | PageClass = lookup.page_class 22 | # this could be incorrect if the player advances right after liveSend is executed. 23 | # maybe just return if it doesn't match. (but leave it in for now and see how much that occurs, 24 | # don't want silent failures.) 25 | if page_name != PageClass.__name__: 26 | logger.warning( 27 | f'Ignoring liveSend message from {participant_code} because ' 28 | f'they are on page {PageClass.__name__}, not {page_name}.' 29 | ) 30 | return 31 | 32 | player = models_module.Player.objects_get( 33 | round_number=lookup.round_number, participant=participant 34 | ) 35 | 36 | # it makes sense to check the group first because 37 | # if the player forgot to define it on the Player, 38 | # we shouldn't fall back to checking the group. you could get an error like 39 | # 'Group' has no attribute 'live_auction' which would be confusing. 40 | # also, we need this 'group' object anyway. 41 | # and this is a good place to show the deprecation warning. 42 | group = player.group 43 | live_method = PageClass.live_method 44 | 45 | retval = call_live_method_compat(live_method, player, payload) 46 | 47 | if not retval: 48 | return 49 | if not isinstance(retval, dict): 50 | msg = f'live method must return a dict' 51 | raise LiveMethodBadReturnValue(msg) 52 | 53 | Player: BasePlayer = models_module.Player 54 | pcodes_dict = { 55 | d[0]: d[1] 56 | for d in Player.objects_filter(group=group) 57 | .join(Participant) 58 | .with_entities(Player.id_in_group, Participant.code,) 59 | } 60 | 61 | if 0 in retval: 62 | if len(retval) > 1: 63 | raise LiveMethodBadReturnValue( 64 | 'If dict returned by live_method has key 0, it must not contain any other keys' 65 | ) 66 | else: 67 | for pid in retval: 68 | if pid not in pcodes_dict: 69 | msg = f'live_method has invalid return value. No player with id_in_group={repr(pid)}' 70 | raise LiveMethodBadReturnValue(msg) 71 | 72 | pcode_retval = {} 73 | for pid, pcode in pcodes_dict.items(): 74 | payload = retval.get(pid, retval.get(0)) 75 | if payload is not None: 76 | pcode_retval[pcode] = payload 77 | 78 | await _live_send_back( 79 | participant._session_code, participant._index_in_pages, pcode_retval 80 | ) 81 | 82 | 83 | class LiveMethodBadReturnValue(Exception): 84 | pass 85 | 86 | 87 | async def _live_send_back(session_code, page_index, pcode_retval): 88 | '''separate function for easier patching''' 89 | 90 | for pcode, retval in pcode_retval.items(): 91 | group_name = channel_utils.live_group(session_code, page_index, pcode) 92 | await channel_utils.group_send( 93 | group=group_name, data=retval, 94 | ) 95 | 96 | 97 | def call_live_method_compat(live_method, player, payload): 98 | if isinstance(live_method, str): 99 | return player.call_user_defined(live_method, payload) 100 | # noself style 101 | return live_method(player, payload) 102 | -------------------------------------------------------------------------------- /otree/locale/ko/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) 2019 THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , 2019. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: otree-core\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2021-10-18 23:30+0200\n" 11 | "PO-Revision-Date: 2019-02-12 16:12+0900\n" 12 | "Last-Translator: Namun Cho \n" 13 | "Language: ko\n" 14 | "Language-Team: Namun Cho \n" 15 | "Plural-Forms: nplurals=1; plural=0\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=utf-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Generated-By: Babel 2.9.0\n" 20 | 21 | #. Translators: A player's default chat nickname, 22 | #. which is "Player" + their ID in group. For example: 23 | #. "Player 2". 24 | #: ../otree/chat.py:47 25 | #, fuzzy, python-brace-format 26 | msgid "Participant {id_in_group}" 27 | msgstr "플레이어 {id_in_group}" 28 | 29 | #. Translators: the name you see in chat for yourself, for example: 30 | #. John (Me) 31 | #: ../otree/chat.py:63 32 | #, python-brace-format 33 | msgid "{nickname} (Me)" 34 | msgstr "{nickname} (본인)" 35 | 36 | #: ../otree/constants.py:79 37 | msgid "This field is required." 38 | msgstr "이 항목은 필수입니다." 39 | 40 | #. Translators: display a number of points, 41 | #. like "1 point", "2 points", ... 42 | #. See "Plural-Forms" above for pluralization rules 43 | #. in this language. 44 | #. Explanation at http://bit.ly/1IurMu7 45 | #. In most languages, msgstr[0] is singular, 46 | #. and msgstr[1] is plural 47 | #. the {} represents the number; 48 | #. don't forget to include it in your translation 49 | #: ../otree/currency.py:199 50 | msgid "{} point" 51 | msgid_plural "{} points" 52 | msgstr[0] "{} 포인트" 53 | 54 | #: ../otree/forms/forms.py:178 55 | msgid "Yes" 56 | msgstr "네" 57 | 58 | #: ../otree/forms/forms.py:178 59 | msgid "No" 60 | msgstr "아니오" 61 | 62 | #. Translators: the label next to a "points" input field 63 | #: ../otree/forms/widgets.py:75 64 | msgid "points" 65 | msgstr "포인트" 66 | 67 | #: ../otree/templates/otree/Page.html:22 68 | msgid "Please fix the errors." 69 | msgstr "입력 양식의 내용이 잘못되었습니다. 바로잡아주세요." 70 | 71 | #: ../otree/templates/otree/RoomInputLabel.html:7 72 | msgid "Welcome" 73 | msgstr "환영합니다" 74 | 75 | #: ../otree/templates/otree/RoomInputLabel.html:13 76 | msgid "This participant label was not found" 77 | msgstr "잘못된 접근입니다. 다시 시도해주시기 바랍니다." 78 | 79 | #: ../otree/templates/otree/RoomInputLabel.html:15 80 | msgid "Please enter your participant label." 81 | msgstr "당신의 아이디(participant label)를 입력해주세요." 82 | 83 | #: ../otree/templates/otree/WaitPage.html:46 84 | msgid "An error occurred." 85 | msgstr "오류가 발생했습니다. 로그를 점검하거나 관리자에게 도움을 요청하시기 바랍니다." 86 | 87 | #: ../otree/templates/otree/tags/chat.html:7 88 | msgid "Send" 89 | msgstr "보내기" 90 | 91 | #. Translators: the text of the 'next' button 92 | #: ../otree/templating/nodes.py:784 93 | msgid "Next" 94 | msgstr "다음" 95 | 96 | #: ../otree/views/abstract.py:788 97 | msgid "Time left to complete this page:" 98 | msgstr "화면 종료까지 남은 시간:" 99 | 100 | #. Translators: the default title of a wait page 101 | #: ../otree/views/abstract.py:814 ../otree/views/participant.py:261 102 | msgid "Please wait" 103 | msgstr "기다려 주세요" 104 | 105 | #: ../otree/views/abstract.py:1186 106 | msgid "Waiting for the other participants." 107 | msgstr "다른 참가자들을 기다리고 있습니다." 108 | 109 | #: ../otree/views/abstract.py:1188 110 | msgid "Waiting for the other participant." 111 | msgstr "다른 참가자를 기다리고 있습니다." 112 | 113 | #. Translators: for example this is shown if you create a session for 10 114 | #. participant. The 11th person to click will get this message 115 | #. It means all participant slots have already been used. 116 | #: ../otree/views/participant.py:34 117 | msgid "Session is full." 118 | msgstr "" 119 | 120 | #: ../otree/views/participant.py:262 121 | msgid "Waiting for your session to begin" 122 | msgstr "세션 시작을 기다리는중" 123 | -------------------------------------------------------------------------------- /otree/locale/id/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # Indonesian translations for PROJECT. 2 | # Copyright (C) 2021 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2021. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PROJECT VERSION\n" 9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 10 | "POT-Creation-Date: 2021-10-18 23:30+0200\n" 11 | "PO-Revision-Date: 2022-04-27 12:54+0700\n" 12 | "Last-Translator: \n" 13 | "Language-Team: id \n" 14 | "Language: id\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=utf-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | "Generated-By: Babel 2.9.0\n" 20 | "X-Generator: Poedit 3.0.1\n" 21 | 22 | #. Translators: A player's default chat nickname, 23 | #. which is "Player" + their ID in group. For example: 24 | #. "Player 2". 25 | #: ../otree/chat.py:47 26 | msgid "Participant {id_in_group}" 27 | msgstr "Peserta {id_in_group}" 28 | 29 | #. Translators: the name you see in chat for yourself, for example: 30 | #. John (Me) 31 | #: ../otree/chat.py:63 32 | msgid "{nickname} (Me)" 33 | msgstr "{nickname} (Saya)" 34 | 35 | #: ../otree/constants.py:79 36 | msgid "This field is required." 37 | msgstr "Wajib diisi." 38 | 39 | #. Translators: display a number of points, 40 | #. like "1 point", "2 points", ... 41 | #. See "Plural-Forms" above for pluralization rules 42 | #. in this language. 43 | #. Explanation at http://bit.ly/1IurMu7 44 | #. In most languages, msgstr[0] is singular, 45 | #. and msgstr[1] is plural 46 | #. the {} represents the number; 47 | #. don't forget to include it in your translation 48 | #: ../otree/currency.py:199 49 | msgid "{} point" 50 | msgid_plural "{} points" 51 | msgstr[0] "{} poin" 52 | msgstr[1] "{} poin" 53 | 54 | #: ../otree/forms/forms.py:178 55 | msgid "Yes" 56 | msgstr "Ya" 57 | 58 | #: ../otree/forms/forms.py:178 59 | msgid "No" 60 | msgstr "Tidak" 61 | 62 | #. Translators: the label next to a "points" input field 63 | #: ../otree/forms/widgets.py:75 64 | msgid "points" 65 | msgstr "poin" 66 | 67 | #: ../otree/templates/otree/Page.html:22 68 | msgid "Please fix the errors." 69 | msgstr "Perbaiki kesalahan tersebut." 70 | 71 | #: ../otree/templates/otree/RoomInputLabel.html:7 72 | msgid "Welcome" 73 | msgstr "Selamat datang" 74 | 75 | #: ../otree/templates/otree/RoomInputLabel.html:13 76 | msgid "This participant label was not found" 77 | msgstr "Label peserta tidak ditemukan" 78 | 79 | #: ../otree/templates/otree/RoomInputLabel.html:15 80 | msgid "Please enter your participant label." 81 | msgstr "Masukkan label peserta Anda." 82 | 83 | #: ../otree/templates/otree/WaitPage.html:46 84 | msgid "An error occurred." 85 | msgstr "Kesalahan telah ditemukan." 86 | 87 | #: ../otree/templates/otree/tags/chat.html:7 88 | msgid "Send" 89 | msgstr "Kirim" 90 | 91 | #. Translators: the text of the 'next' button 92 | #: ../otree/templating/nodes.py:784 93 | msgid "Next" 94 | msgstr "Berikutnya" 95 | 96 | #: ../otree/views/abstract.py:788 97 | msgid "Time left to complete this page:" 98 | msgstr "Waktu yang tersisa untuk halaman ini:" 99 | 100 | #. Translators: the default title of a wait page 101 | #: ../otree/views/abstract.py:814 ../otree/views/participant.py:261 102 | msgid "Please wait" 103 | msgstr "Mohon Tunggu" 104 | 105 | #: ../otree/views/abstract.py:1186 106 | msgid "Waiting for the other participants." 107 | msgstr "Menunggu peserta lainnya." 108 | 109 | #: ../otree/views/abstract.py:1188 110 | msgid "Waiting for the other participant." 111 | msgstr "Menunggu peserta lainnya." 112 | 113 | #. Translators: for example this is shown if you create a session for 10 114 | #. participant. The 11th person to click will get this message 115 | #. It means all participant slots have already been used. 116 | #: ../otree/views/participant.py:34 117 | msgid "Session is full." 118 | msgstr "Sesi ini penuh." 119 | 120 | #: ../otree/views/participant.py:262 121 | msgid "Waiting for your session to begin" 122 | msgstr "Menunggu sesi Anda untuk dimulai" 123 | -------------------------------------------------------------------------------- /otree/templates/otree/tags/chat.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 | 6 | {% comment %}Translators: Chat widget "send" button text{% endcomment %} 7 | 8 |
9 | 10 | 11 | 31 | 32 | 33 | 132 | -------------------------------------------------------------------------------- /otree/forms/fields.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | import wtforms.fields as wtfields 3 | from otree.currency import Currency, to_dec 4 | from otree.i18n import format_number, core_gettext 5 | from otree import common 6 | 7 | from . import widgets as wg 8 | 9 | 10 | def handle_localized_number_input(val): 11 | if val is None: 12 | return val 13 | return val.replace(',', '.') 14 | 15 | 16 | class FloatField(wtfields.FloatField): 17 | widget = wg.FloatWidget() 18 | 19 | def process_formdata(self, valuelist): 20 | if valuelist: 21 | try: 22 | self.data = float(handle_localized_number_input(valuelist[0])) 23 | except ValueError: 24 | self.data = None 25 | # hack: hide this from pybabel, which seems to scan for all 26 | # functions that end with 'gettext('. 27 | # wtforms already contains these translations, so they should 28 | # not end up in our .po file. 29 | _gt = self.gettext 30 | raise ValueError(_gt('Not a valid float value')) 31 | 32 | def _value(self): 33 | if self.data is None: 34 | return '' 35 | return format_number(self.data, places=common.FULL_DECIMAL_PLACES) 36 | 37 | 38 | class CurrencyField(wtfields.Field): 39 | widget = wg.CurrencyWidget() 40 | 41 | def process_formdata(self, valuelist): 42 | if valuelist and valuelist[0]: 43 | try: 44 | data = Currency(handle_localized_number_input(valuelist[0])) 45 | except (decimal.InvalidOperation, ValueError): 46 | self.data = None 47 | # see the note above about gettext 48 | _gt = self.gettext 49 | raise ValueError(_gt('Not a valid decimal value')) 50 | else: 51 | data = None 52 | self.data = data 53 | 54 | def _value(self): 55 | if self.data is None: 56 | return '' 57 | return format_number(to_dec(self.data), places=common.FULL_DECIMAL_PLACES) 58 | 59 | 60 | class StringField(wtfields.StringField): 61 | widget = wg.TextInput() 62 | 63 | 64 | class IntegerField(wtfields.IntegerField): 65 | widget = wg.IntegerWidget() 66 | 67 | 68 | def _selectfield_getitem(self, index): 69 | if not isinstance(index, int): 70 | raise IndexError 71 | for (i, choice) in enumerate(self): 72 | if index == i: 73 | return choice 74 | raise IndexError 75 | 76 | 77 | def __iter__(self): 78 | """ 79 | Add 'required' attribute to HTML: 80 | https://github.com/wtforms/wtforms/pull/615/files 81 | """ 82 | opts = dict( 83 | widget=self.option_widget, 84 | _name=self.name, 85 | _form=None, 86 | _meta=self.meta, 87 | validators=self.validators, 88 | ) 89 | for i, (value, label, checked) in enumerate(self.iter_choices()): 90 | opt = self._Option(label=label, id='%s-%d' % (self.id, i), **opts) 91 | opt.process(None, value) 92 | opt.checked = checked 93 | yield opt 94 | 95 | 96 | class RadioField(wtfields.RadioField): 97 | widget = wg.RadioSelect() 98 | option_widget = wg.RadioOption() 99 | __getitem__ = _selectfield_getitem 100 | __iter__ = __iter__ 101 | 102 | 103 | class RadioFieldHorizontal(wtfields.RadioField): 104 | widget = wg.RadioSelectHorizontal() 105 | option_widget = wg.RadioOption() 106 | __getitem__ = _selectfield_getitem 107 | __iter__ = __iter__ 108 | 109 | 110 | class DropdownField(wtfields.SelectField): 111 | widget = wg.Select() 112 | option_widget = wg.SelectOption() 113 | __getitem__ = _selectfield_getitem 114 | __iter__ = __iter__ 115 | 116 | 117 | class TextAreaField(StringField): 118 | """ 119 | This field represents an HTML ``