├── requirements.txt ├── runtime.txt ├── otreeutils_example1 ├── __init__.py ├── migrations │ └── __init__.py ├── tests.py ├── models.py ├── _builtin │ └── __init__.py ├── templates │ └── otreeutils_example1 │ │ ├── ExtendedPageWithTimeoutWarning.html │ │ └── PageWithCustomURLName.html └── pages.py ├── otreeutils_example2 ├── __init__.py ├── migrations │ └── __init__.py ├── templates │ └── otreeutils_example2 │ │ └── SurveyIntro.html ├── _builtin │ └── __init__.py ├── pages.py ├── tests.py └── models.py ├── otreeutils ├── templatetags │ ├── __init__.py │ └── otreeutils_tags.py ├── __init__.py ├── static │ └── otreeutils │ │ ├── understanding.css │ │ ├── surveys.css │ │ └── understanding.js ├── admin_extensions │ ├── __init__.py │ ├── urls.py │ └── views.py ├── templates │ └── otreeutils │ │ ├── forms │ │ └── radio_select_horizontal.html │ │ ├── UnderstandingQuestionsPage.html │ │ ├── ExtendedPage.html │ │ ├── admin │ │ └── SessionDataExtension.html │ │ └── SurveyPage.html ├── scripts.py ├── pages.py └── surveys.py ├── otreeutils_example3_market ├── __init__.py ├── urls.py ├── _builtin │ └── __init__.py ├── templates │ └── otreeutils_example3_market │ │ ├── Results.html │ │ ├── PurchasePage.html │ │ └── CreateOffersPage.html ├── README.md ├── models.py ├── tests.py └── pages.py ├── Procfile ├── img └── likerttable.png ├── MANIFEST.in ├── .gitignore ├── manage.py ├── tox.ini ├── example_export_script.py ├── setup.py ├── settings.py ├── CHANGES.md ├── LICENSE └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | -e .[all] -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.5.2 2 | -------------------------------------------------------------------------------- /otreeutils_example1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /otreeutils_example2/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /otreeutils/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /otreeutils_example3_market/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /otreeutils_example1/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /otreeutils_example2/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: otree webandworkers 2 | timeoutworker: otree timeoutworker 3 | -------------------------------------------------------------------------------- /img/likerttable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WZBSocialScienceCenter/otreeutils/HEAD/img/likerttable.png -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include otreeutils/static * 4 | recursive-include otreeutils/templates * 5 | -------------------------------------------------------------------------------- /otreeutils/templatetags/otreeutils_tags.py: -------------------------------------------------------------------------------- 1 | from django.template.defaulttags import register 2 | 3 | 4 | @register.filter 5 | def get_form_field(form, field): 6 | return form[field] 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | *.pyc 3 | staticfiles 4 | ./db.sqlite3 5 | .idea 6 | *~ 7 | *.sqlite3 8 | _coverage_results/ 9 | build/ 10 | dist/ 11 | otreeutils.egg-info/ 12 | Makefile 13 | __temp_static_root/ 14 | .cache/ 15 | __temp_migrations/ 16 | tmp/ 17 | -------------------------------------------------------------------------------- /otreeutils/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = 'otreeutils' 2 | __version__ = '0.10.0' 3 | __author__ = 'Markus Konrad' 4 | __license__ = 'Apache License 2.0' 5 | 6 | try: 7 | import pandas as pd 8 | from . import admin_extensions # only import admin_extensions when pandas is available 9 | except ImportError: pass 10 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 8 | 9 | from otree.management.cli import execute_from_command_line 10 | execute_from_command_line(sys.argv, script_file=__file__) 11 | -------------------------------------------------------------------------------- /otreeutils_example3_market/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom URLs as explained in https://otree.readthedocs.io/en/latest/misc/django.html#adding-custom-pages-urls 3 | 4 | This adds URLs for custom admin views (session data and export) from otreeutils. 5 | 6 | Sept. 2018, Markus Konrad 7 | """ 8 | 9 | from otreeutils.admin_extensions.urls import urlpatterns 10 | -------------------------------------------------------------------------------- /otreeutils_example1/tests.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from . import pages 4 | from ._builtin import Bot 5 | 6 | 7 | class PlayerBot(Bot): 8 | def play_round(self): 9 | yield (pages.SomeUnderstandingQuestions, { 10 | 'understanding_questions_wrong_attempts': random.randint(0, 10), 11 | }) 12 | 13 | yield (pages.ExtendedPageWithTimeoutWarning, ) 14 | -------------------------------------------------------------------------------- /otreeutils_example2/templates/otreeutils_example2/SurveyIntro.html: -------------------------------------------------------------------------------- 1 | {% extends "global/Base.html" %} 2 | {% load staticfiles otree_tags %} 3 | 4 | {% block title %} 5 | Survey introduction 6 | {% endblock %} 7 | 8 | {% block content %} 9 | 10 |

This example shows the usage of the otreeutils.surveys module.

11 | 12 | {% next_button %} 13 | 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /otreeutils/static/otreeutils/understanding.css: -------------------------------------------------------------------------------- 1 | .questions li .hint { 2 | font-size: 0.7em; 3 | color: darkred; 4 | } 5 | 6 | .fake-next { 7 | text-align: right; 8 | } 9 | 10 | label.ok { 11 | color: darkgreen; 12 | } 13 | 14 | label.error { 15 | color: darkred; 16 | } 17 | 18 | input.ok { 19 | border: 1px solid darkgreen; 20 | } 21 | 22 | input.error { 23 | border: 1px solid darkred; 24 | } -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py{37,38}-otree{330,3311} 8 | skipsdist = True 9 | 10 | [testenv] 11 | deps = 12 | otree330: otree==3.3.0 13 | otree3311: otree==3.3.11 14 | commands = otree test 15 | -------------------------------------------------------------------------------- /otreeutils/admin_extensions/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Admin backend extensions provided by otreeutils. 3 | 4 | Feb. 2021, Markus Konrad 5 | """ 6 | 7 | 8 | def custom_export(players): 9 | """ 10 | Default function for custom data export with linked custom models. 11 | """ 12 | from .views import get_rows_for_custom_export 13 | 14 | if not players: 15 | yield [] 16 | else: 17 | app_name = players[0]._meta.app_config.name 18 | 19 | yield from get_rows_for_custom_export(app_name) 20 | -------------------------------------------------------------------------------- /otreeutils/templates/otreeutils/forms/radio_select_horizontal.html: -------------------------------------------------------------------------------- 1 | {% with id=widget.attrs.id %} 2 | {% comment %} 3 | the 'widget' variable is a dict, NOT the widget instance 4 | {% endcomment %} 5 | {% for group, options, index in widget.optgroups %} 6 | {% for option in options %} 7 |
8 | {% include "django/forms/widgets/input.html" with widget=option %} 9 | 10 |
11 | {% endfor %} 12 | {% endfor %} 13 | {% endwith %} 14 | -------------------------------------------------------------------------------- /otreeutils_example1/models.py: -------------------------------------------------------------------------------- 1 | from otree.api import ( 2 | models, widgets, BaseConstants, BaseSubsession, BaseGroup, BasePlayer, 3 | Currency as c, currency_range 4 | ) 5 | 6 | 7 | author = 'Markus Konrad' 8 | 9 | doc = """ 10 | Example 1 for usage of the otreeutils package. 11 | """ 12 | 13 | 14 | class Constants(BaseConstants): 15 | name_in_url = 'otreeutils_example1' 16 | players_per_group = None 17 | num_rounds = 1 18 | 19 | 20 | class Subsession(BaseSubsession): 21 | pass 22 | 23 | 24 | class Group(BaseGroup): 25 | pass 26 | 27 | 28 | class Player(BasePlayer): 29 | understanding_questions_wrong_attempts = models.PositiveIntegerField() # number of wrong attempts on understanding quesions page 30 | -------------------------------------------------------------------------------- /otreeutils_example3_market/_builtin/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is auto-generated. 2 | # It's used to aid autocompletion in code editors. 3 | 4 | import otree.api 5 | from .. import models 6 | 7 | 8 | class Page(otree.api.Page): 9 | def z_autocomplete(self): 10 | self.subsession = models.Subsession() 11 | self.group = models.Group() 12 | self.player = models.Player() 13 | 14 | 15 | class WaitPage(otree.api.WaitPage): 16 | def z_autocomplete(self): 17 | self.subsession = models.Subsession() 18 | self.group = models.Group() 19 | 20 | 21 | class Bot(otree.api.Bot): 22 | def z_autocomplete(self): 23 | self.subsession = models.Subsession() 24 | self.group = models.Group() 25 | self.player = models.Player() 26 | -------------------------------------------------------------------------------- /otreeutils_example1/_builtin/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is auto-generated. 2 | # It's used to aid autocompletion in code editors. 3 | 4 | import otree.api 5 | from .. import models 6 | 7 | 8 | class Page(otree.api.Page): 9 | def z_autocomplete(self): 10 | self.subsession = models.Subsession() 11 | self.group = models.Group() 12 | self.player = models.Player() 13 | 14 | 15 | class WaitPage(otree.api.WaitPage): 16 | def z_autocomplete(self): 17 | self.subsession = models.Subsession() 18 | self.group = models.Group() 19 | 20 | 21 | class Bot(otree.api.Bot): 22 | def z_autocomplete(self): 23 | self.subsession = models.Subsession() 24 | self.group = models.Group() 25 | self.player = models.Player() 26 | -------------------------------------------------------------------------------- /otreeutils_example2/_builtin/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is auto-generated. 2 | # It's used to aid autocompletion in code editors. 3 | 4 | import otree.api 5 | from .. import models 6 | 7 | 8 | class Page(otree.api.Page): 9 | def z_autocomplete(self): 10 | self.subsession = models.Subsession() 11 | self.group = models.Group() 12 | self.player = models.Player() 13 | 14 | 15 | class WaitPage(otree.api.WaitPage): 16 | def z_autocomplete(self): 17 | self.subsession = models.Subsession() 18 | self.group = models.Group() 19 | 20 | 21 | class Bot(otree.api.Bot): 22 | def z_autocomplete(self): 23 | self.subsession = models.Subsession() 24 | self.group = models.Group() 25 | self.player = models.Player() 26 | -------------------------------------------------------------------------------- /otreeutils/admin_extensions/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom URLs that add hooks to session data monitor extensions. 3 | 4 | Feb. 2021, Markus Konrad 5 | """ 6 | 7 | from django.conf.urls import url 8 | from otree.urls import urlpatterns 9 | 10 | from . import views 11 | 12 | 13 | # define patterns with name, URL pattern and view class 14 | patterns_conf = { 15 | 'SessionData': (r"^SessionData/(?P[a-z0-9]+)/$", views.SessionDataExtension), 16 | 'SessionDataAjax': (r"^session_data/(?P[a-z0-9]+)/$", views.SessionDataAjaxExtension), 17 | } 18 | 19 | # exclude oTree's original patterns with the same names 20 | urlpatterns = [pttrn for pttrn in urlpatterns if pttrn.name not in patterns_conf.keys()] 21 | 22 | # add the patterns 23 | for name, (pttrn, viewclass) in patterns_conf.items(): 24 | urlpatterns.append(url(pttrn, viewclass.as_view(), name=name)) 25 | -------------------------------------------------------------------------------- /otreeutils_example1/templates/otreeutils_example1/ExtendedPageWithTimeoutWarning.html: -------------------------------------------------------------------------------- 1 | {% extends 'otreeutils/ExtendedPage.html' %} 2 | 3 | {% load staticfiles otree_tags %} 4 | 5 | {% block content %} 6 | 7 |

8 | A timeout warning after 10 seconds with a custom warning message. No auto-submit or anything like that. 9 |

10 | 11 |

This is your page class:

12 | 13 |
14 | class ExtendedPageWithTimeoutWarning(ExtendedPage):
15 |     timeout_warning_seconds = 10
16 |     timeout_warning_message = "You're too slow. Hurry up!"
17 | 
18 | 19 |

Don't forget this at the beginning of you template file:

20 | 21 |
22 | {% verbatim %}
23 | {% extends "otreeutils/ExtendedPage.html" %}
24 | {% endverbatim %}
25 | 
26 | 27 |

You can also use timeout warnings in understanding questions pages.

28 | 29 | {% next_button %} 30 | 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /otreeutils_example1/templates/otreeutils_example1/PageWithCustomURLName.html: -------------------------------------------------------------------------------- 1 | {% extends 'otreeutils/ExtendedPage.html' %} 2 | 3 | {% load staticfiles otree_tags %} 4 | 5 | {% block content %} 6 | 7 |

8 | By default, oTree uses the page's class name in its URL, so since this page's class name 9 | is PageWithCustomURLName this page's URL would be something like https://.../p/.../otreeutils_example1/PageWithCustomURLName/.... 10 |

11 | 12 |

13 | However, sometimes you don't want to show the class name in the URL to a user, especially if it's an online experiment and the page name conveys 14 | some information the user shouldn't know. You can then either rename the page class (and its template) or you simply use ExtendedPage 15 | as base class and set the custom_name_in_url = 'foobar' attribute as shown in this example. 16 |

17 | 18 | {% next_button %} 19 | 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /example_export_script.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example script to output *all* data for given list of apps as hierarchical data structure in JSON format. 3 | 4 | Feb. 2021, Markus Konrad 5 | """ 6 | 7 | import sys 8 | 9 | from otreeutils import scripts # this is the most import line and must be included at the beginning 10 | 11 | 12 | if len(sys.argv) != 2: 13 | print('call this script with a single argument: python %s ' % sys.argv[0]) 14 | exit(1) 15 | 16 | output_file = sys.argv[1] 17 | 18 | apps = ['otreeutils_example1', 19 | 'otreeutils_example2', 20 | 'otreeutils_example3_market'] 21 | 22 | print('loading data...') 23 | 24 | # get the data as hierarchical data structure. this is esp. useful if you use 25 | # custom data models 26 | combined = scripts.get_hierarchical_data_for_apps(apps) 27 | 28 | print('writing data to file', output_file) 29 | 30 | scripts.save_data_as_json_file(combined, output_file, indent=2) 31 | 32 | print('done.') 33 | -------------------------------------------------------------------------------- /otreeutils/templates/otreeutils/UnderstandingQuestionsPage.html: -------------------------------------------------------------------------------- 1 | {% extends 'otreeutils/ExtendedPage.html' %} 2 | 3 | {% load staticfiles otree_tags %} 4 | 5 | {% block app_styles %} 6 | 7 | {% endblock %} 8 | 9 | {% block app_scripts %} 10 | 11 | {% endblock %} 12 | 13 | {% block content %} 14 | 15 |
    16 | {{ questions_form.as_ul }} 17 |
18 | 19 |
20 | {% next_button %} 21 |
22 | 23 |
24 | 26 |
27 | 28 | 33 | {% endblock %} 34 | 35 | 36 | -------------------------------------------------------------------------------- /otreeutils/scripts.py: -------------------------------------------------------------------------------- 1 | """ 2 | oTree extension to write own shell scripts. 3 | 4 | This is uses a lot of code copied from the "otree_startup" package, which is part of "otree-core" (see 5 | https://github.com/oTree-org/otree-core). 6 | 7 | March 2021, Markus Konrad 8 | """ 9 | 10 | import os 11 | import sys 12 | import logging 13 | import json 14 | 15 | # code to setup the oTree/Django environment (locate and load settings module, setup django) 16 | 17 | from otree_startup import configure_settings, do_django_setup 18 | 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | if os.getcwd() not in sys.path: 23 | sys.path.insert(0, os.getcwd()) 24 | 25 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings') 26 | DJANGO_SETTINGS_MODULE = os.environ['DJANGO_SETTINGS_MODULE'] 27 | 28 | configure_settings(DJANGO_SETTINGS_MODULE) 29 | do_django_setup() 30 | 31 | 32 | from .admin_extensions.views import get_hierarchical_data_for_apps 33 | 34 | 35 | def save_data_as_json_file(data, path, **kwargs): 36 | from django.core.serializers.json import DjangoJSONEncoder 37 | 38 | with open(path, 'w') as f: 39 | json.dump(data, f, cls=DjangoJSONEncoder, **kwargs) 40 | -------------------------------------------------------------------------------- /otreeutils/static/otreeutils/surveys.css: -------------------------------------------------------------------------------- 1 | div.survey_form .field_container { 2 | margin-bottom: 2em; 3 | } 4 | 5 | table.survey_form { 6 | border-collapse: collapse; 7 | margin-bottom: 2em; 8 | width: 100%; 9 | } 10 | 11 | table.survey_form .form-check-input { 12 | position: static; 13 | margin: 0; 14 | } 15 | 16 | table.survey_form th { 17 | padding: 0 0.5em 0 0.5em; 18 | } 19 | 20 | table.survey_form tr.header th { 21 | border-left: 1px solid black; 22 | border-bottom: 1px solid black; 23 | } 24 | 25 | table.survey_form tr.header th.first { 26 | border-left: none; 27 | } 28 | 29 | table.survey_form tr.header th { 30 | text-align: center; 31 | } 32 | 33 | table.survey_form td { 34 | text-align: center; 35 | border-left: 1px solid black; 36 | } 37 | 38 | table.survey_form th, 39 | table.survey_form td { 40 | padding: 0.4em 0.2em 0 0.2em; 41 | } 42 | 43 | table.survey_form tr.even { 44 | background: lightgray; 45 | } 46 | 47 | table.survey_form tr.formrow.active { 48 | background: #f0f8ff; 49 | } 50 | 51 | table.survey_form tr.formrow.even.active { 52 | background: #dae0e5; 53 | } 54 | 55 | table.survey_form tr.formrow td.active { 56 | background: #eff0ff; 57 | } 58 | 59 | table.survey_form tr.formrow.even td.active { 60 | background: #d7d8e5; 61 | } 62 | -------------------------------------------------------------------------------- /otreeutils_example3_market/templates/otreeutils_example3_market/Results.html: -------------------------------------------------------------------------------- 1 | {% extends "global/Page.html" %} 2 | {% load otree static %} 3 | 4 | {% block styles %} 5 | 7 | {% endblock %} 8 | 9 | {% block title %} 10 | Results — round {{ subsession.round_number }} 11 | {% endblock %} 12 | 13 | {% block content %} 14 | 15 |

You are a {{ player.role }}.

16 | 17 | {% if player.role == 'buyer' %} 18 |

You bought the following fruits:

19 | 20 | {% if transactions %} 21 |
    22 | {% for t in transactions %} 23 |
  • {{ t.amount }} pieces of {{ t.fruit.kind }} for {{ t.fruit.price }} per piece from seller {{ t.fruit.seller.participant.id }}
  • 24 | {% endfor %} 25 |
26 | {% else %} 27 |

You didn't buy any fruits.

28 | {% endif %} 29 | 30 |

You spent {{ balance_change }} on this and now have a balance of {{ player.balance }}.

31 | {% else %} 32 | {% if transactions %} 33 |

You sold the following things:

34 | {% for t in transactions %} 35 |
  • {{ t.amount }} pieces of {{ t.fruit.kind }} for {{ t.fruit.price }} per piece to buyer {{ t.buyer.participant.id }}
  • 36 | {% endfor %} 37 | {% else %} 38 |

    You didn't sell any fruits.

    39 | {% endif %} 40 | 41 |

    You gained {{ balance_change }} from this and now have a balance of {{ player.balance }}.

    42 | {% endif %} 43 | 44 |

    45 | {% next_button %} 46 |

    47 | 48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /otreeutils_example1/pages.py: -------------------------------------------------------------------------------- 1 | from otree.api import Currency as c, currency_range 2 | from . import models 3 | from ._builtin import Page, WaitPage 4 | from .models import Constants 5 | 6 | from otreeutils.pages import AllGroupsWaitPage, ExtendedPage, UnderstandingQuestionsPage, APPS_DEBUG 7 | 8 | 9 | class SomeUnderstandingQuestions(UnderstandingQuestionsPage): 10 | page_title = 'Example 1.1: A page with some understanding questions' 11 | set_correct_answers = False # do not fill out the correct answers in advance (this is for fast skipping through pages) 12 | form_model = models.Player 13 | form_field_n_wrong_attempts = 'understanding_questions_wrong_attempts' 14 | questions = [ 15 | { 16 | 'question': 'What is 2+2?', 17 | 'options': [2, 4, 5, 8], 18 | 'correct': 4, 19 | }, 20 | { 21 | 'question': 'What is π?', 22 | 'options': [1.2345, 3.14159], 23 | 'correct': 3.14159, 24 | 'hint': 'You can have a look at Wikipedia!' 25 | }, 26 | { 27 | 'question': 'Is this too easy?', 28 | 'options': ['Yes', 'No', 'Maybe'], 29 | 'correct': 'Yes', 30 | }, 31 | ] 32 | 33 | 34 | class ExtendedPageWithTimeoutWarning(ExtendedPage): 35 | page_title = 'Example 1.2: A page with timeout warning.' 36 | timeout_warning_seconds = 10 37 | timeout_warning_message = "You're too slow. Hurry up!" 38 | 39 | 40 | class PageWithCustomURLName(ExtendedPage): 41 | custom_name_in_url = 'foobar' 42 | 43 | 44 | page_sequence = [ 45 | SomeUnderstandingQuestions, 46 | AllGroupsWaitPage, 47 | ExtendedPageWithTimeoutWarning, 48 | PageWithCustomURLName, 49 | ] 50 | -------------------------------------------------------------------------------- /otreeutils_example3_market/README.md: -------------------------------------------------------------------------------- 1 | # Example implementation for an experiment with dynamically determined data quantity in oTree 2 | 3 | July/Sept. 2018, Markus Konrad / [Berlin Social Science Center](https://wzb.eu) 4 | 5 | Updated in March 2021 for compatibility with oTree 3.3.x. 6 | 7 | This repository contains the companion code for the article [*oTree: Implementing experiments with dynamically determined data quantity*](https://doi.org/10.1016/j.jbef.2018.10.006) published in a Special Issue on "Software for Experimental Economics" in the *Journal of Behavioral and Experimental Finance*. 8 | 9 | The experiment "market" that is provided as [oTree](http://www.otree.org/) application serves as a illustrative example for a simple stylized market simulation. Many individuals (1 ... *N*-1) are selling fruit. In each round, these sellers choose a kind of fruit and a selling price, whereas individual *N* (the buyer) needs to choose from which of those offers to buy. The implemenation follows the principle suggested in the paper, relying on "custom data models" from oTree's underlying Django framework. 10 | 11 | This project also illustrates how the admin interface extensions of the package *[otreeutils](https://github.com/WZBSocialScienceCenter/otreeutils)* can be integrated in an experiment. This adds the functionality to observe data updates from custom data models in oTree's "session data viewer". Additionally, data exports for CSV and Excel contain all data from custom data models and an option to export the data in JSON format is available. 12 | 13 | **Please note:** This is not a complete experiment but only a stripped-down example for illustrative purposes. This means for example that some sanity checks like checking for negative balances are not implemented. 14 | 15 | ## Requirements 16 | 17 | This project requires otree and [otreeutils 0.10 or newer](https://github.com/WZBSocialScienceCenter/otreeutils). 18 | 19 | The code has been tested with oTree v3.3.11 but should run on new oTree versions of the 3.3.x branch. 20 | 21 | ## License 22 | 23 | Apache License 2.0. See LICENSE file. 24 | -------------------------------------------------------------------------------- /otreeutils_example2/pages.py: -------------------------------------------------------------------------------- 1 | # Definition of views/pages for the survey. 2 | # Please note: When using oTree 2.x, this file should be called "pages.py" instead of "views.py" 3 | # 4 | 5 | from otree.api import Currency as c, currency_range 6 | from . import models 7 | from ._builtin import Page, WaitPage 8 | from .models import Constants 9 | 10 | from otreeutils.surveys import SurveyPage, setup_survey_pages 11 | 12 | 13 | class SurveyIntro(Page): 14 | pass 15 | 16 | 17 | # let's create the survey pages here 18 | # unfortunately, it's not possible to create them dynamically 19 | 20 | 21 | class SurveyPage1(SurveyPage): 22 | pass 23 | 24 | 25 | class SurveyPage2(SurveyPage): 26 | pass 27 | 28 | 29 | class SurveyPage3(SurveyPage): 30 | pass 31 | 32 | 33 | class SurveyPage4(SurveyPage): 34 | pass 35 | 36 | 37 | class SurveyPage5(SurveyPage): 38 | def get_context_data(self, **kwargs): 39 | # get the context data generated by SurveyPage 40 | ctx_data = super().get_context_data(**kwargs) 41 | 42 | # remove the form we don't want to display for the respective treatment 43 | if self.player.treatment == 1: 44 | remove_form = 2 45 | else: 46 | remove_form = 1 47 | 48 | del ctx_data['survey_forms']['treatment_%d_form' % remove_form] 49 | 50 | # return the modified context data 51 | return ctx_data 52 | 53 | 54 | class SurveyPage6(SurveyPage): 55 | pass 56 | 57 | 58 | class SurveyPage7(SurveyPage): 59 | debug_fill_forms_randomly = True # enable random data input if APPS_DEBUG is True 60 | 61 | 62 | # Create a list of survey pages. 63 | # The order is important! The survey questions are taken in the same order 64 | # from the SURVEY_DEFINITIONS in models.py 65 | 66 | survey_pages = [ 67 | SurveyPage1, 68 | SurveyPage2, 69 | SurveyPage3, 70 | SurveyPage4, 71 | SurveyPage5, 72 | SurveyPage6, 73 | SurveyPage7 74 | ] 75 | 76 | # Common setup for all pages (will set the questions per page) 77 | setup_survey_pages(models.Player, survey_pages) 78 | 79 | page_sequence = [ 80 | SurveyIntro, 81 | ] 82 | 83 | # add the survey pages to the page sequence list 84 | page_sequence.extend(survey_pages) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | otreeutils setuptools based setup module 3 | """ 4 | 5 | import os 6 | 7 | from setuptools import setup, find_packages 8 | 9 | __title__ = 'otreeutils' 10 | __version__ = '0.10.0' 11 | __author__ = 'Markus Konrad' 12 | __license__ = 'Apache License 2.0' 13 | 14 | here = os.path.abspath(os.path.dirname(__file__)) 15 | 16 | GITHUB_URL = 'https://github.com/WZBSocialScienceCenter/otreeutils' 17 | 18 | DEPS_BASE = ['otree>=3.3,<4'] 19 | 20 | DEPS_EXTRA = { 21 | 'admin': ['pandas>=1.0,<1.3'], 22 | 'develop': ['tox>=3.21.0,<3.22', 'twine>=3.1.0,<3.2'] 23 | } 24 | 25 | DEPS_EXTRA['all'] = [] 26 | for k, deps in DEPS_EXTRA.items(): 27 | if k != 'all': 28 | DEPS_EXTRA['all'].extend(deps) 29 | 30 | 31 | # Get the long description from the README file 32 | with open(os.path.join(here, 'README.md')) as f: 33 | long_description = f.read() 34 | 35 | setup( 36 | name=__title__, 37 | version=__version__, 38 | description='Facilitate oTree experiment implementation with extensions for custom data models, surveys, understanding questions, timeout warnings and more.', 39 | long_description=long_description, 40 | long_description_content_type='text/markdown', 41 | url=GITHUB_URL, 42 | project_urls={ 43 | 'Bug Reports': GITHUB_URL + '/issues', 44 | 'Source': GITHUB_URL, 45 | }, 46 | 47 | author=__author__, 48 | author_email='markus.konrad@wzb.eu', 49 | 50 | license=__license__, 51 | 52 | classifiers=[ 53 | 'Development Status :: 4 - Beta', 54 | 'Intended Audience :: Developers', 55 | 56 | 'Environment :: Web Environment', 57 | 58 | 'Framework :: Django', 59 | 60 | 'License :: OSI Approved :: Apache Software License', 61 | 62 | 'Operating System :: OS Independent', 63 | 'Programming Language :: Python', 64 | 'Programming Language :: Python :: 3', 65 | 'Programming Language :: Python :: 3.7', 66 | 'Programming Language :: Python :: 3.8', 67 | 'Topic :: Internet :: WWW/HTTP', 68 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 69 | ], 70 | 71 | keywords='otree experiments social science finance economics development', 72 | 73 | packages=find_packages(exclude=['otreeutils_example*']), 74 | include_package_data=True, 75 | 76 | install_requires=DEPS_BASE, 77 | extras_require=DEPS_EXTRA 78 | ) 79 | -------------------------------------------------------------------------------- /otreeutils_example2/tests.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from . import pages, models 4 | from ._builtin import Bot 5 | 6 | 7 | def rand_val_from_choices(choices): 8 | return random.choice([k for k, _ in choices]) 9 | 10 | 11 | class PlayerBot(Bot): 12 | def play_round(self): 13 | yield (pages.SurveyIntro, ) 14 | 15 | yield (pages.SurveyPage1, { 16 | 'q_age': random.randint(18, 100), 17 | 'q_gender': rand_val_from_choices(models.GENDER_CHOICES), 18 | }) 19 | 20 | yield (pages.SurveyPage2, { 21 | 'q_otree_surveys': random.randint(1, len(models.likert_5_labels)), 22 | 'q_just_likert': random.randint(1, len(models.likert_5_labels)), 23 | 'q_likert_htmllabels': random.randint(1, len(models.likert_5_labels_html)), 24 | 'q_likert_centered': random.randint(-2, len(models.likert_5_labels) - 3), 25 | 'q_likert_labeled': random.choice(models.likert_5point_values), 26 | }) 27 | 28 | yield (pages.SurveyPage3, { 29 | 'q_student': rand_val_from_choices(models.YESNO_CHOICES), 30 | 'q_field_of_study': rand_val_from_choices([('', None), ('Sociology', None), ('Psychology', None)]), 31 | 'q_otree_years': random.randint(0, 10), 32 | }) 33 | 34 | likert_table_rows = ( 35 | 'tasty', 36 | 'spicy', 37 | 'cold', 38 | 'satiable' 39 | ) 40 | 41 | likert_table_rows2 = ( 42 | 'tasty', 43 | 'spicy' 44 | ) 45 | 46 | likert_table_data = {'q_pizza_' + k: random.randint(1, len(models.likert_5_labels)) for k in likert_table_rows} 47 | likert_table_data2 = {'q_hotdog_' + k: random.choice(models.likert_5point_values) for k in likert_table_rows2} 48 | likert_table_data.update(likert_table_data2) 49 | yield (pages.SurveyPage4, likert_table_data) 50 | 51 | yield (pages.SurveyPage5, { 52 | 'q_treatment_%d' % self.player.treatment: rand_val_from_choices(models.YESNO_CHOICES) 53 | }) 54 | 55 | p6_data = {'q_uses_ebay': rand_val_from_choices(models.YESNO_CHOICES)} 56 | if p6_data['q_uses_ebay'] == 'yes': 57 | p6_data.update({ 58 | 'q_ebay_member_years': random.randint(1, 10), 59 | 'q_ebay_sales_per_week': rand_val_from_choices(models.EBAY_ITEMS_PER_WEEK) 60 | }) 61 | yield (pages.SurveyPage6, p6_data) 62 | -------------------------------------------------------------------------------- /otreeutils/static/otreeutils/understanding.js: -------------------------------------------------------------------------------- 1 | var N_QUESTIONS = null; 2 | var HINT_TEXT_EMPTY = null; 3 | var input_n_wrong_attempts = null; 4 | 5 | 6 | function checkUnderstandingQuestionsForm() { 7 | var n_correct = 0; 8 | for (var q_idx = 0; q_idx < N_QUESTIONS; q_idx++) { 9 | var input_id = 'id_q_input_' + q_idx; 10 | var input_field = $('#' + input_id); 11 | var label = $('label[for=' + input_id + ']'); 12 | var v = input_field.val(); 13 | var correct = $('#id_q_correct_' + q_idx).val(); 14 | 15 | if (v == correct) { 16 | input_field.removeClass('error').addClass('ok'); 17 | label.removeClass('error').addClass('ok'); 18 | n_correct++; 19 | } else { 20 | input_field.removeClass('ok').addClass('error'); 21 | label.removeClass('ok').addClass('error'); 22 | 23 | var input_parent = input_field.parent(); 24 | if (input_parent.find('.hint').length == 0) { 25 | var hint_text; 26 | if (v == '') { 27 | hint_text = HINT_TEXT_EMPTY; 28 | } else { 29 | hint_text = $('#id_q_hint_' + q_idx).val(); 30 | } 31 | 32 | var hint = '

    ' + hint_text + '

    '; 33 | input_parent.append(hint); 34 | } 35 | } 36 | } 37 | 38 | if (n_correct == N_QUESTIONS) { 39 | $('#form').submit(); 40 | } else { 41 | var cur_n_wrong_attempts = parseInt(input_n_wrong_attempts.val()); 42 | if (isNaN(cur_n_wrong_attempts)) { 43 | cur_n_wrong_attempts = 0; 44 | } 45 | input_n_wrong_attempts.val(cur_n_wrong_attempts + 1); 46 | } 47 | } 48 | 49 | 50 | function setupUnderstandingQuestionsForm(n_questions, hit_text_empty, field_n_wrong_attempts, set_correct_answers) { 51 | N_QUESTIONS = n_questions; 52 | HINT_TEXT_EMPTY = hit_text_empty; 53 | input_n_wrong_attempts = $('#id_' + field_n_wrong_attempts); 54 | 55 | for (var q_idx = 0; q_idx < N_QUESTIONS; q_idx++) { 56 | var input_id = 'id_q_input_' + q_idx; 57 | var input_field = $('#' + input_id); 58 | var label = $('label[for=' + input_id + ']'); 59 | 60 | if (set_correct_answers) { 61 | var correct = $('#id_q_correct_' + q_idx).val(); 62 | input_field.val(correct); 63 | } 64 | 65 | input_field.focus(function (e) { // reset classes function 66 | var inp = $(e.target); 67 | var par = inp.parent(); 68 | var lbl = $('label[for=' + inp.prop('id') + ']'); 69 | inp.removeClass('ok').removeClass('error'); 70 | lbl.removeClass('ok').removeClass('error'); 71 | par.find('.hint').remove(); 72 | }); 73 | } 74 | } -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import importlib.util 2 | from os import environ 3 | 4 | # SET THIS IN YOUR OWN EXPERIMENTS 5 | SECRET_KEY = '...' 6 | 7 | # List of example experiments 8 | 9 | SESSION_CONFIGS = [ 10 | { 11 | 'name': 'otreeutils_example1', 12 | 'display_name': 'otreeutils example 1 (Understanding questions, timeout warnings, custom page URLs)', 13 | 'num_demo_participants': 1, # doesn't matter 14 | 'app_sequence': ['otreeutils_example1'], 15 | }, 16 | { 17 | 'name': 'otreeutils_example2', 18 | 'display_name': 'otreeutils example 2 (Surveys)', 19 | 'num_demo_participants': 4, # every second player gets treatment 2 20 | 'app_sequence': ['otreeutils_example2'], 21 | }, 22 | { # the following experiments are only available when you install otreeutils as `pip install otreeutils[admin]` 23 | 'name': 'otreeutils_example3_market', 24 | 'display_name': 'otreeutils example 3 (Custom data models: Market)', 25 | 'num_demo_participants': 3, # at least two 26 | 'app_sequence': ['otreeutils_example3_market'], 27 | }, 28 | { 29 | 'name': 'otreeutils_example4_market_and_survey', 30 | 'display_name': 'otreeutils example 4 (Market and survey)', 31 | 'num_demo_participants': 3, # at least two 32 | 'app_sequence': ['otreeutils_example3_market', 'otreeutils_example2'], 33 | } 34 | ] 35 | 36 | # if you set a property in SESSION_CONFIG_DEFAULTS, it will be inherited by all configs 37 | # in SESSION_CONFIGS, except those that explicitly override it. 38 | # the session config can be accessed from methods in your apps as self.session.config, 39 | # e.g. self.session.config['participation_fee'] 40 | 41 | SESSION_CONFIG_DEFAULTS = dict( 42 | real_world_currency_per_point=1.00, participation_fee=0.00, doc="" 43 | ) 44 | 45 | # ISO-639 code 46 | # for example: de, fr, ja, ko, zh-hans 47 | LANGUAGE_CODE = 'en' 48 | 49 | # e.g. EUR, GBP, CNY, JPY 50 | REAL_WORLD_CURRENCY_CODE = 'USD' 51 | USE_POINTS = False 52 | 53 | ROOMS = [ 54 | dict( 55 | name='econ101', 56 | display_name='Econ 101 class', 57 | participant_label_file='_rooms/econ101.txt', 58 | ), 59 | dict(name='live_demo', display_name='Room for live demo (no participant labels)'), 60 | ] 61 | 62 | ADMIN_USERNAME = 'admin' 63 | # for security, best to set admin password in an environment variable 64 | ADMIN_PASSWORD = environ.get('OTREE_ADMIN_PASSWORD') 65 | 66 | DEMO_PAGE_INTRO_HTML = """ 67 | otreeutils examples 68 | """ 69 | 70 | 71 | # the environment variable OTREE_PRODUCTION controls whether Django runs in 72 | # DEBUG mode. If OTREE_PRODUCTION==1, then DEBUG=False 73 | 74 | if environ.get('OTREE_PRODUCTION') not in {None, '', '0'}: 75 | DEBUG = False 76 | APPS_DEBUG = False 77 | else: 78 | DEBUG = True 79 | APPS_DEBUG = True # will set a debug variable to true in the template files 80 | 81 | # if an app is included in SESSION_CONFIGS, you don't need to list it here 82 | INSTALLED_APPS = [ 83 | 'otree', 84 | 'otreeutils' # this is important -- otherwise otreeutils' templates and static files won't be accessible 85 | ] 86 | 87 | 88 | # custom URL and WebSockets configuration 89 | # this is important -- otherwise otreeutils' admin extensions won't be activated 90 | 91 | if importlib.util.find_spec('pandas'): 92 | ROOT_URLCONF = 'otreeutils_example3_market.urls' 93 | -------------------------------------------------------------------------------- /otreeutils_example3_market/templates/otreeutils_example3_market/PurchasePage.html: -------------------------------------------------------------------------------- 1 | {% extends "global/Page.html" %} 2 | {% load otree static %} 3 | 4 | {% block styles %} 5 | 21 | {% endblock %} 22 | 23 | {% block title %} 24 | Purchase fruits — round {{ subsession.round_number }} 25 | {% endblock %} 26 | 27 | {% block content %} 28 | 29 |

    You are a {{ player.role }}.

    30 | 31 | {% if player.role == 'buyer' %} 32 |

    You have an initial balance of {{ player.initial_balance }}.

    33 |

    The following things are offered on the market:

    34 | 35 | {{ purchases_formset.management_form }} 36 | 37 |
      38 | {% for offer, purchase_form in offers_with_forms %} 39 | {{ purchase_form.fruit.as_hidden }} 40 |
    • 41 | {{ offer.kind }} for {{ offer.price }} per piece from seller {{ offer.seller.participant.id }} 42 | — buy {{ purchase_form.amount }} pieces ({{ offer.amount }} available) 43 |
    • 44 | {% endfor %} 45 |
    46 | 47 |

    Your balance is now:

    48 | {% else %} 49 |

    You're offering the following things:

    50 | 51 |
      52 | {% for offer in sellers_offers %} 53 |
    • {{ offer.amount }} pieces of {{ offer.kind }} for {{ offer.price }} a piece
    • 54 | {% endfor %} 55 |
    56 | 57 |

    Your products are now being offered on the market. Just click "Next" and wait until the customer has finished.

    58 | {% endif %} 59 | 60 |

    61 | {% next_button %} 62 |

    63 | 64 | {% if player.role == 'buyer' %} 65 | 104 | {% endif %} 105 | 106 | {% endblock %} 107 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Changes 2 | 3 | 4 | ## v0.10.0 (for oTree v3.3.x) - upcoming 5 | 6 | * added compatibility for oTree v3.3.x -- dropped support for older oTree versions 7 | * better integration in session data monitor 8 | * provide default `custom_export()` function for apps with linked custom data models 9 | * `surveys` module: 10 | * made `generate_likert_field()` more flexible with parameters `field`, `choices_values` and `html_labels` 11 | * added option to pass parameters to `generate_likert_field()` for `generate_likert_table()` 12 | * adapted examples to show new features 13 | * fixed bug in `otreeutils_example3_market` example experiment, where amount of fruit in offers was not decreased after sales 14 | * check if otreeutils is listed in `INSTALLED_APPS` 15 | * make dependency to pandas optional (only installed with `admin_extensions` option) 16 | * integrated `tox` for testing 17 | 18 | ## v0.9.2 (for oTree v2.1.x) – 2019-09-23 19 | 20 | * `surveys` module: 21 | * added missing "change" triggers when checkboxes are selected in Likert table via clicking/touching the table cell 22 | * added a check to require the `survey_definitions` argument to be a tuple in `create_player_model_for_survey` 23 | 24 | ## v0.9.1 (for oTree v2.1.x) – 2019-06-13 25 | 26 | * `surveys` module: 27 | * added option `table_repeat_header_each_n_rows` to `generate_likert_table()` 28 | * fixed problem where form options like `form_help_initial` were ignored 29 | 30 | ## v0.9.0 (for oTree v2.1.x) – 2019-05-28 31 | 32 | * `surveys` module: 33 | * add several options to `generate_likert_table()` to adjust display and behavior of Likert tables 34 | * allow non-survey form fields on survey pages 35 | 36 | ## v0.8.0 (for oTree v2.0.x) – 2019-05-15 37 | 38 | * pages derived from `ExtendedPage` may set `debug_fill_forms_randomly` to `True` so that when visiting the page, its form is filled in with random values (helpful during developement process) 39 | * `surveys` module: all columns in a Likert table now have the same width. The width of row header (first column) is 25% by default and can be changed via `table_row_header_width_pct` 40 | 41 | ## v0.7.1 (for oTree v2.0.x) – 2019-05-07 42 | 43 | * `surveys` module: field labels can now contain HTML (HTML is not escaped and will be rendered) 44 | 45 | ## v0.7.0 (for oTree v2.0.x) – 2019-04-09 46 | 47 | * added class attribute `custom_name_in_url` for `ExtendedPage`: allows to set a custom URL for a page (instead of default class name) 48 | * several improvements in the `surveys` module: 49 | * added `other_fields` parameter to `create_player_model_for_survey` to allow for additional (non-survey) fields 50 | * corrections when using surveys module in several apps on the same server instance 51 | * added option to specify conditional field display via JavaScript 52 | * added option to specify form label suffix 53 | * added option to specify field widget HTML attributes 54 | * added option to use custom choices (`use_likert_scale=False`) in `generate_likert_table` 55 | * alternating row colors and hover for likert table 56 | * added new `scripts` module: 57 | * properly set up your command-line scripts to work with oTree by importing the module 58 | * export of hierarchical data structures from collected data 59 | 60 | 61 | ## v0.6.0 (for oTree v2.0.x) – 2019-02-28 62 | 63 | * added new features to `otreeutils.surveys`: 64 | * `generate_likert_field` to easily create Likert scale fields from given labels 65 | * `generate_likert_table` to easily create a table of Likert scale inputs 66 | * ability to add `help_text` for each question field as HTML 67 | * ability to split questions into several forms 68 | * easier survey forms styling via CSS due to more structured HTML output 69 | 70 | ## v0.5.1 (for oTree v2.0.x) – 2019-02-18 71 | 72 | * fixed problem with missing sub-package `otreeutils.admin_extensions` 73 | 74 | ## v0.5.0 (for oTree v2.0.x) – 2018-10-02 75 | 76 | * modified admin extensions to use pandas for data joins, removes limitation in live data viewer 77 | * fixed issue with tests for example 1 and 2 78 | * added example 3: market with custom data models 79 | 80 | ## v0.4.1 (for oTree v2.0.x) – 2018-09-28 81 | 82 | * fixed template error in `admin/SessionDataExtension.html` 83 | 84 | ## v0.4.0 (for oTree v2.0.x) – 2018-09-27 85 | 86 | * added admin extensions: 87 | * live session data with custom data models 88 | * app data export with custom data models in CSV, Excel and JSON formats 89 | * dropped support for oTree v1.x 90 | * fixed some minor compat. issues with latest oTree version 91 | 92 | ## v0.3.0 (for oTree v1.x) – 2018-04-25 93 | 94 | * made compatible with oTree v2.0 95 | * updated setuptools configuration 96 | * added this changelog 97 | -------------------------------------------------------------------------------- /otreeutils_example3_market/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Model definitions including "custom data models" `FruitOffer` and `Purchase`. 3 | 4 | March 2021, Markus Konrad 5 | """ 6 | 7 | import random 8 | 9 | from otree.api import ( 10 | models, widgets, BaseConstants, BaseSubsession, BaseGroup, BasePlayer, 11 | Currency as c, currency_range 12 | ) 13 | 14 | from otree.db.models import ForeignKey, Model 15 | from otreeutils.admin_extensions import custom_export 16 | 17 | 18 | author = 'Markus Konrad' 19 | 20 | doc = """ 21 | Example experiment: selling/buying products on a market. 22 | Implemented with custom data models. 23 | 24 | Many individuals (1 ... N-1) are selling fruit with two attributes (e.g. kind of fruit and price). Each chooses a kind 25 | and a price. Then individual N needs to choose which fruit to buy. 26 | """ 27 | 28 | 29 | class Constants(BaseConstants): 30 | name_in_url = 'market' 31 | players_per_group = None 32 | num_rounds = 3 33 | 34 | 35 | class Subsession(BaseSubsession): 36 | def creating_session(self): # oTree 2 method name (used to be before_session_starts) 37 | if self.round_number == 1: 38 | # for each player, set a random initial balance in the first round 39 | for p in self.get_players(): 40 | p.initial_balance = c(random.triangular(1, 20, 10)) 41 | p.balance = p.initial_balance 42 | 43 | 44 | class Group(BaseGroup): # we're not using groups 45 | pass 46 | 47 | 48 | class Player(BasePlayer): 49 | initial_balance = models.CurrencyField() # balance at the start of the round 50 | balance = models.CurrencyField() # balance at the end of the round 51 | 52 | def role(self): 53 | """ 54 | Define the role of each player. The first player is always the "buyer" i.e. customer whereas all other players 55 | are selling fruit. 56 | """ 57 | if self.id_in_group == 1: 58 | return 'buyer' 59 | else: 60 | return 'seller' 61 | 62 | 63 | class FruitOffer(Model): 64 | """ 65 | Custom data model derived from Django's generic `Model` class. This class defines an offer of fruit with three 66 | properties: 67 | - amount of available fruit 68 | - selling price per item 69 | - kind of fruit 70 | 71 | Additionally, a reference to the seller is stored via a `ForeignKey` to `Player`. 72 | """ 73 | 74 | KINDS = ( 75 | ('Apple', 'Apple'), 76 | ('Orange', 'Orange'), 77 | ('Banana', 'Banana'), 78 | ) 79 | PURCHASE_PRICES = { 80 | 'Apple': c(0.20), 81 | 'Orange': c(0.30), 82 | 'Banana': c(0.50), 83 | } 84 | 85 | amount = models.IntegerField(label='Amount', min=0, initial=0) # number of fruits available 86 | price = models.CurrencyField(label='Price per fruit', min=0, initial=0) 87 | kind = models.StringField(choices=KINDS) 88 | # easy to add more attributes per fruit, e.g.: 89 | #is_organic = models.BooleanField() # if True: organic fruit, else conventional 90 | 91 | # creates many-to-one relation -> this fruit is sold by a certain player, a player can sell many fruits 92 | seller = ForeignKey(Player, on_delete=models.CASCADE) 93 | 94 | class CustomModelConf: 95 | """ 96 | Configuration for otreeutils admin extensions. 97 | This class and its attributes must be existent in order to include this model in the data viewer / data export. 98 | """ 99 | data_view = { 100 | 'exclude_fields': ['seller_id'], 101 | 'link_with': 'seller' 102 | } 103 | export_data = { 104 | 'exclude_fields': ['seller_id'], 105 | 'link_with': 'seller' 106 | } 107 | 108 | 109 | class Purchase(Model): 110 | """ 111 | Custom data model derived from Django's generic `Model` class. This class defines a purchase made by a certain 112 | customer (buyer) for a certain fruit. Hence it stores a reference to a buyer via a `ForeignKey` to `Player` and 113 | a reference to a fruit offer via a `ForeignKey` to `FruitOffer`. Additionally, the amount of fruit bought is 114 | stored. 115 | """ 116 | 117 | amount = models.IntegerField(min=1) # fruits taken 118 | # price = models.CurrencyField(min=0) optional: allow bargaining 119 | 120 | fruit = ForeignKey(FruitOffer, on_delete=models.CASCADE) # creates many-to-one relation -> this purchase 121 | # relates to a certain fruit offer 122 | # many purchases can be made for this offer (as long 123 | # as there's at least 1 fruit left) 124 | buyer = ForeignKey(Player, on_delete=models.CASCADE) # creates many-to-one relation -> this fruit is bought 125 | # by a certain player *in a certain round*. a player 126 | # can buy many fruits. 127 | 128 | class CustomModelConf: 129 | """ 130 | Configuration for otreeutils admin extensions. 131 | This class and its attributes must be existent in order to include this model in the data viewer / data export. 132 | """ 133 | data_view = { 134 | 'exclude_fields': ['buyer_id'], 135 | 'link_with': 'buyer' 136 | } 137 | export_data = { 138 | 'exclude_fields': ['buyer_id'], 139 | 'link_with': 'buyer' 140 | } -------------------------------------------------------------------------------- /otreeutils/pages.py: -------------------------------------------------------------------------------- 1 | """ 2 | oTree page extensions. 3 | 4 | March 2021, Markus Konrad 5 | """ 6 | 7 | 8 | import settings 9 | 10 | from django import forms 11 | from django.utils.translation import ugettext as _ 12 | 13 | from otree.api import Page, WaitPage 14 | 15 | APPS_DEBUG = getattr(settings, 'APPS_DEBUG', False) 16 | 17 | 18 | class AllGroupsWaitPage(WaitPage): 19 | """A wait page that waits for all groups to arrive.""" 20 | wait_for_all_groups = True 21 | 22 | 23 | class ExtendedPage(Page): 24 | """Base page class with extended functionality.""" 25 | page_title = '' 26 | custom_name_in_url = None 27 | timer_warning_text = None 28 | timeout_warning_seconds = None # set this to enable a timeout warning -- no form submission, just a warning 29 | timeout_warning_message = 'Please hurry up, the time is over!' 30 | debug = APPS_DEBUG 31 | debug_fill_forms_randomly = False 32 | 33 | def __init__(self, **kwargs): 34 | super(ExtendedPage, self).__init__(**kwargs) 35 | from django.conf import settings 36 | 37 | if 'otreeutils' not in settings.INSTALLED_APPS: 38 | raise RuntimeError('otreeutils is missing from the INSTALLED_APPS list in your oTree settings ' 39 | 'file (settings.py); please refer to ' 40 | 'https://github.com/WZBSocialScienceCenter/otreeutils#installation-and-setup ' 41 | 'for more help') 42 | 43 | @classmethod 44 | def url_pattern(cls, name_in_url): 45 | if cls.custom_name_in_url: 46 | return r'^p/(?P\w+)/{}/{}/(?P\d+)/$'.format( 47 | name_in_url, 48 | cls.custom_name_in_url, 49 | ) 50 | else: 51 | return super(ExtendedPage, cls).url_pattern(name_in_url) 52 | 53 | @classmethod 54 | def get_url(cls, participant_code, name_in_url, page_index): 55 | if cls.custom_name_in_url: 56 | return r'/p/{pcode}/{name_in_url}/{custom_name_in_url}/{page_index}/'.format( 57 | pcode=participant_code, name_in_url=name_in_url, 58 | custom_name_in_url=cls.custom_name_in_url, page_index=page_index 59 | ) 60 | else: 61 | return super(ExtendedPage, cls).get_url(participant_code, name_in_url, page_index) 62 | 63 | # @classmethod 64 | # def url_name(cls): 65 | # if cls.custom_name_in_url: 66 | # return cls.custom_name_in_url.replace('.', '-') 67 | # else: 68 | # return super().url_name() 69 | 70 | @classmethod 71 | def has_timeout_warning(cls): 72 | return cls.timeout_warning_seconds is not None and cls.timeout_warning_seconds > 0 73 | 74 | def get_template_names(self): 75 | if self.__class__ is ExtendedPage: 76 | return ['otreeutils/ExtendedPage.html'] 77 | else: 78 | return super(ExtendedPage, self).get_template_names() 79 | 80 | def get_page_title(self): 81 | """Override this method for a dynamic page title""" 82 | return self.page_title 83 | 84 | def get_context_data(self, **kwargs): 85 | ctx = super(ExtendedPage, self).get_context_data(**kwargs) 86 | default_timer_warning_text = getattr(self, 'timer_text', _("Time left to complete this page:")) 87 | ctx.update({ 88 | 'page_title': self.get_page_title(), 89 | 'timer_warning_text': self.timer_warning_text or default_timer_warning_text, 90 | 'timeout_warning_seconds': self.timeout_warning_seconds, 91 | 'timeout_warning_message': self.timeout_warning_message, 92 | 'debug': int(self.debug), # allows to retrieve a debug state in the templates 93 | 'debug_fill_forms_randomly': int(self.debug and self.debug_fill_forms_randomly) 94 | }) 95 | 96 | return ctx 97 | 98 | 99 | class UnderstandingQuestionsPage(ExtendedPage): 100 | """ 101 | A page base class to implement understanding questions. 102 | Displays questions as defined in "questions" list. 103 | Optionally record the number of unsuccessful attempts for solving the questions. 104 | """ 105 | default_hint = 'This is wrong. Please reconsider.' 106 | default_hint_empty = 'Please fill out this answer.' 107 | questions = [] # define the understanding questions here. add dicts with the following keys: "question", "options", "correct" 108 | set_correct_answers = True # useful for skipping pages during development 109 | debug_fill_forms_randomly = False # not used -- use set_correct_answers 110 | template_name = 'otreeutils/UnderstandingQuestionsPage.html' # reset to None to use your own template that extends this one 111 | form_field_n_wrong_attempts = None # optionally record number of wrong attempts in this field (set form_model then, too!) 112 | form_fields = [] # no need to change this 113 | form_model = None 114 | 115 | def get_questions(self): 116 | """Override this method to return a dynamic list of questions""" 117 | return self.questions 118 | 119 | def get_form_fields(self): 120 | if self.form_model: 121 | form_fields = super(UnderstandingQuestionsPage, self).get_form_fields() 122 | 123 | if self.form_field_n_wrong_attempts: # update form fields 124 | form_fields.append(self.form_field_n_wrong_attempts) 125 | 126 | return form_fields 127 | else: 128 | return None 129 | 130 | def vars_for_template(self): 131 | """Sets variables for template: Question form and additional data""" 132 | # create question form 133 | form = _UnderstandingQuestionsForm() 134 | 135 | # add questions to form 136 | questions = self.get_questions() 137 | for q_idx, q_def in enumerate(questions): 138 | answer_field = forms.ChoiceField(label=q_def['question'], 139 | choices=_choices_for_field(q_def['options'])) 140 | correct_val_field = forms.CharField(initial=q_def['correct'], 141 | widget=forms.HiddenInput) 142 | hint_field = forms.CharField(initial=q_def.get('hint', self.default_hint), 143 | widget=forms.HiddenInput) 144 | form.add_field('q_input_%d' % q_idx, answer_field) 145 | form.add_field('q_correct_%d' % q_idx, correct_val_field) 146 | form.add_field('q_hint_%d' % q_idx, hint_field) 147 | 148 | # optionally add field with number of wrong attempts 149 | if self.form_model and self.form_field_n_wrong_attempts: 150 | form.add_field(self.form_field_n_wrong_attempts, forms.CharField(initial=0, widget=forms.HiddenInput)) 151 | 152 | return { 153 | 'questions_form': form, 154 | 'n_questions': len(questions), 155 | 'hint_empty': self.default_hint_empty, 156 | 'form_field_n_wrong_attempts': self.form_field_n_wrong_attempts or '', 157 | 'set_correct_answers': str(self.set_correct_answers and self.debug).lower(), 158 | } 159 | 160 | 161 | def _choices_for_field(opts, add_empty=True): 162 | """Create a list of tuples for choices in a form field.""" 163 | if add_empty: 164 | choices = [('', '---')] 165 | else: 166 | choices = [] 167 | 168 | choices.extend([(o, str(o)) for o in opts]) 169 | 170 | return choices 171 | 172 | 173 | class _UnderstandingQuestionsForm(forms.Form): 174 | def add_field(self, name, field): 175 | self.fields[name] = field 176 | -------------------------------------------------------------------------------- /otreeutils_example3_market/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | Automated tests for the experiment. 3 | 4 | July 2018, Markus Konrad 5 | """ 6 | 7 | import random 8 | 9 | from otree.api import Currency as c, currency_range, Submission 10 | from . import pages 11 | from ._builtin import Bot 12 | from .models import Constants, FruitOffer, Purchase 13 | 14 | 15 | def _fill_submitdata(submitdata, objs, i): 16 | for k, v in objs.items(): 17 | submitdata['form-%d-%s' % (i, k)] = v 18 | 19 | 20 | class PlayerBot(Bot): 21 | def _create_offers_input(self): 22 | if self.round_number == 1: 23 | existing_offers = [] 24 | else: 25 | # prev_round = self.subsession.round_number - 1 26 | # prev_player = self.player.in_round(prev_round) 27 | 28 | existing_offers = list(FruitOffer.objects.filter(seller=self.player)) 29 | 30 | n_new_offers = random.randint(0, 5) 31 | offer_objs = existing_offers + [None] * n_new_offers 32 | 33 | submitdata = {} 34 | offers_properties = [] 35 | for i, prev_offer in enumerate(offer_objs): 36 | if prev_offer is not None: 37 | offer = { 38 | 'id': prev_offer.pk, 39 | 'old_amount': prev_offer.amount, 40 | 'amount': 0, 41 | 'price': float(prev_offer.price), 42 | 'kind': prev_offer.kind 43 | } 44 | else: 45 | offer = {'old_amount': 0} 46 | 47 | rand_data = { 48 | 'amount': random.randint(0, 10), 49 | 'price': round(random.uniform(0, 3), 2) 50 | } 51 | 52 | if prev_offer is None or random.choice((1, 0)): 53 | offer.update(rand_data) 54 | 55 | if prev_offer is None: 56 | offer['kind'] = random.choice(FruitOffer.KINDS)[0] 57 | 58 | _fill_submitdata(submitdata, offer, i) 59 | 60 | offer['new_amount'] = offer['old_amount'] + offer['amount'] 61 | 62 | if offer['new_amount'] > 0: 63 | offers_properties.append(offer) 64 | 65 | # formset metadata 66 | submitdata['form-TOTAL_FORMS'] = len(offer_objs) 67 | submitdata['form-INITIAL_FORMS'] = len(existing_offers) 68 | submitdata['form-MIN_NUM_FORMS'] = len(existing_offers) 69 | submitdata['form-MAX_NUM_FORMS'] = 1000 70 | 71 | return submitdata, offers_properties 72 | 73 | def _check_offers(self, offers_properties): 74 | if offers_properties is not None: 75 | offers_properties = offers_properties[:] # copy 76 | 77 | saved_offers = FruitOffer.objects.filter(seller=self.player) 78 | 79 | if self.player.role() == 'buyer': 80 | assert len(saved_offers) == 0, 'buyer should not offer anything' 81 | assert offers_properties is None 82 | else: 83 | assert len(saved_offers) == len(offers_properties) 84 | 85 | # check saved offers 86 | n_matches = 0 87 | for o1 in saved_offers: 88 | assert o1.seller == self.player, 'seller is not current player' 89 | 90 | for i_o2, o2 in enumerate(offers_properties): 91 | if 'id' in o2: 92 | ids_match = o1.pk == o2['id'] 93 | else: 94 | ids_match = True 95 | 96 | if o1.kind == o2['kind'] and o1.amount == o2['new_amount'] and o1.price == c(o2['price'])\ 97 | and ids_match: 98 | n_matches += 1 99 | offers_properties.pop(i_o2) 100 | break 101 | 102 | assert len(offers_properties) == 0 and n_matches == len(saved_offers),\ 103 | 'saved offers do not match submitted data' 104 | 105 | def _create_purchases_input(self): 106 | offers = FruitOffer.objects.select_related('seller__subsession', 'seller__participant'). \ 107 | filter(seller__subsession=self.player.subsession). \ 108 | order_by('seller', 'kind') 109 | 110 | submitdata = {} 111 | purchases_properties = [] 112 | for i, o in enumerate(offers): 113 | purchase = { 114 | 'amount': random.randint(0, 3), 115 | 'fruit': o.pk 116 | } 117 | 118 | _fill_submitdata(submitdata, purchase, i) 119 | 120 | if purchase['amount'] > 0: 121 | purchases_properties.append(purchase) 122 | 123 | # formset metadata 124 | submitdata['form-TOTAL_FORMS'] = len(offers) 125 | submitdata['form-INITIAL_FORMS'] = 0 126 | submitdata['form-MIN_NUM_FORMS'] = 0 127 | submitdata['form-MAX_NUM_FORMS'] = len(offers) 128 | 129 | return submitdata, purchases_properties 130 | 131 | def _check_purchases(self, purchases_properties): 132 | if purchases_properties is not None: 133 | purchases_properties = purchases_properties[:] # copy 134 | 135 | saved_purchases = Purchase.objects.filter(buyer=self.player) 136 | 137 | if self.player.role() == 'seller': 138 | assert len(saved_purchases) == 0, 'seller should not purchase anything' 139 | assert purchases_properties is None 140 | else: 141 | assert len(saved_purchases) == len(purchases_properties) 142 | 143 | # check saved purchases 144 | n_matches = 0 145 | for p1 in saved_purchases: 146 | assert p1.buyer == self.player, 'buyer is not current player' 147 | 148 | for i_p2, p2 in enumerate(purchases_properties): 149 | if p1.fruit.pk == p2['fruit'] and p1.amount == p2['amount']: 150 | n_matches += 1 151 | purchases_properties.pop(i_p2) 152 | break 153 | 154 | assert len(purchases_properties) == 0 and n_matches == len(saved_purchases),\ 155 | 'saved purchases do not match submitted data' 156 | 157 | def _check_balance(self, offers_properties, purchases_properties): 158 | if self.player.role() == 'seller': 159 | cost = 0 160 | for o in offers_properties: 161 | cost += o['amount'] * FruitOffer.PURCHASE_PRICES[o['kind']] 162 | 163 | gain = 0 164 | for p in Purchase.objects.select_related('fruit').filter(fruit__seller=self.player): 165 | gain += p.amount * p.fruit.price 166 | 167 | assert self.player.balance == self.player.initial_balance - cost + gain 168 | else: 169 | cost = 0 170 | for p in purchases_properties: 171 | fruit = FruitOffer.objects.get(pk=p['fruit']) 172 | cost += p['amount'] * fruit.price 173 | 174 | assert self.player.balance == self.player.initial_balance - cost 175 | 176 | def play_round(self): 177 | if self.player.role() == 'buyer': 178 | offers_input = None 179 | offers_properties = None 180 | else: 181 | assert self.player.role() == 'seller', 'player role is something other than buyer or seller' 182 | offers_input, offers_properties = self._create_offers_input() 183 | 184 | # disable HTML checking because formset forms are created dynamically with JavaScript 185 | yield Submission(pages.CreateOffersPage, offers_input, check_html=False) 186 | 187 | self._check_offers(offers_properties) 188 | 189 | if self.player.role() == 'buyer': 190 | purchases_input, purchases_properties = self._create_purchases_input() 191 | else: 192 | purchases_input = None 193 | purchases_properties = None 194 | 195 | yield Submission(pages.PurchasePage, purchases_input, check_html=False) 196 | 197 | self._check_purchases(purchases_properties) 198 | 199 | yield Submission(pages.Results) 200 | 201 | self._check_balance(offers_properties, purchases_properties) 202 | -------------------------------------------------------------------------------- /otreeutils_example3_market/templates/otreeutils_example3_market/CreateOffersPage.html: -------------------------------------------------------------------------------- 1 | {% extends "global/Page.html" %} 2 | {% load otree static %} 3 | 4 | {% block styles %} 5 | 34 | {% endblock %} 35 | 36 | {% block title %} 37 | Create offers — round {{ subsession.round_number }} 38 | {% endblock %} 39 | 40 | {% block content %} 41 | 42 |

    You are a {{ player.role }}.

    43 | 44 | {% if player.role == 'buyer' %} 45 |

    The sellers are currently deciding on their offers. Just click "Next" and wait until they finished.

    46 | {% else %} 47 |

    You must decide on your offers now. You have an initial balance of {{ player.initial_balance }}.

    48 | 49 |

    Purchase prices per piece for you as a seller:

    50 |
      51 | {% for fruit_label, price in purchase_prices.items %} 52 |
    • {{ fruit_label }}: {{ price }}
    • 53 | {% endfor %} 54 |
    55 | 56 | {{ offers_formset.management_form }} 57 | 58 |
    59 | {% for offer_form in offers_formset %} 60 |

    Offer #{{ forloop.counter }}

    61 |
      62 | {{ offer_form.as_ul }} 63 |
    64 | {% endfor %} 65 |
    66 | 67 |

    68 | 69 | 75 | 76 |

    Your balance is now:

    77 | {% endif %} 78 | 79 |

    80 | {% next_button %} 81 |

    82 | 83 | {% if player.role == 'seller' %} 84 | 198 | {% endif %} 199 | {% endblock %} 200 | -------------------------------------------------------------------------------- /otreeutils/templates/otreeutils/ExtendedPage.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/Page.html" %} 2 | {% load i18n staticfiles otree %} 3 | 4 | {% block internal_scripts %} 5 | {{ block.super }} 6 | 7 | {% if view.has_timeout_warning %} 8 | 9 | {% endif %} 10 | 11 | 179 | {% endblock %} 180 | 181 | {% block title %} 182 | {{ page_title }} 183 | {% endblock %} 184 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /otreeutils/templates/otreeutils/admin/SessionDataExtension.html: -------------------------------------------------------------------------------- 1 | {% extends "otree/admin/Session.html" %} 2 | 3 | {% block internal_styles %} 4 | {{ block.super }} 5 | 37 | {% endblock %} 38 | 39 | {% block no_container_content %} 40 | {{ block.super }} 41 | 42 | {% for table in tables %} 43 | 44 | 45 | 46 | 47 | {% for header in table.pfields %} 48 | 49 | {% endfor %} 50 | {% for cmodel_name, cmodel_header in table.cfields.items %} 51 | {% for header in cmodel_header %} 52 | 53 | {% endfor %} 54 | {% endfor %} 55 | {% for header in table.gfields %} 56 | 57 | {% endfor %} 58 | {% for header in table.sfields %} 59 | 60 | {% endfor %} 61 | 62 | 63 | 64 | 65 |
    player.
    {{ header }}
    {{ cmodel_name }}.
    {{ header }}
    group.
    {{ header }}
    subsession.
    {{ header }}
    66 | {% endfor %} 67 |
    68 | 69 | 70 | 74 | 78 | 81 | 85 | 86 | 87 | 90 | 91 | 92 | 93 | 94 |
    71 | 72 | 73 | 75 | 76 | 77 | 79 | 80 | 82 | Plain | 83 | Excel 84 |
    88 |
    89 |
    Round
    95 |
    96 | 100 | {% endblock %} 101 | 102 | {% block internal_scripts %} 103 | {{ block.super }} 104 | 265 | {% endblock %} 266 | -------------------------------------------------------------------------------- /otreeutils/templates/otreeutils/SurveyPage.html: -------------------------------------------------------------------------------- 1 | {% extends 'otreeutils/ExtendedPage.html' %} 2 | 3 | {% load staticfiles otree_tags otreeutils_tags %} 4 | 5 | {% block app_styles %} 6 | 7 | {% endblock %} 8 | 9 | {% block content %} 10 | 11 | {{ base_form.non_field_errors }} 12 | 13 | {% for survey_form_name, survey_form in survey_forms.items %} 14 |
    15 | {% if survey_form.form_opts.form_help_initial %} 16 |
    17 | {{ survey_form.form_opts.form_help_initial|safe }} 18 |
    19 | {% endif %} 20 | 21 | {% if survey_form.form_opts.render_type == 'table' %} 22 | 23 | 24 | 25 | {% for header_label in survey_form.form_opts.header_labels %} 26 | 27 | {% endfor %} 28 | 29 | {% for field_name in survey_form.fields %} 30 | {% with field=form|get_form_field:field_name %} 31 | 32 | 37 | {% for choice in field %} 38 | 39 | {% endfor %} 40 | 41 | {% endwith %} 42 | {% endfor %} 43 |
    {{ header_label }}
    44 | 45 | 143 | {% else %} 144 |
    145 | {% for field_name in survey_form.fields %} 146 | {% with field=form|get_form_field:field_name %} 147 | 156 | {% endwith %} 157 | {% endfor %} 158 |
    159 | {% endif %} 160 | 161 | {% if survey_form.form_opts.form_help_final %} 162 |
    163 | {{ survey_form.form_opts.form_help_final|safe }} 164 |
    165 | {% endif %} 166 |
    167 | {% endfor %} 168 | 169 | 215 | 216 | {% next_button %} 217 | 218 | {% endblock %} 219 | 220 | 221 | -------------------------------------------------------------------------------- /otreeutils_example3_market/pages.py: -------------------------------------------------------------------------------- 1 | """ 2 | Page definitions for the experiment. 3 | 4 | March 2021, Markus Konrad 5 | """ 6 | 7 | 8 | from otree.api import Currency as c, currency_range 9 | from ._builtin import Page, WaitPage 10 | from .models import Constants, FruitOffer, Purchase 11 | from django.forms import modelformset_factory, ModelForm, IntegerField, HiddenInput 12 | 13 | 14 | class NormalWaitPage(WaitPage): 15 | # For whatever reason, oTree suddenly issues a warning "page_sequence cannot contain a class called 'WaitPage'"... 16 | # So it appears we have to define an own WaitPage which is exactly the same... 17 | pass 18 | 19 | 20 | class OfferForm(ModelForm): 21 | old_amount = IntegerField(initial=0, widget=HiddenInput) 22 | 23 | class Meta: 24 | model = FruitOffer 25 | fields = ('old_amount', 'kind', 'amount', 'price') 26 | 27 | 28 | def get_offers_formset(): 29 | """Helper method that returns a Django formset for a dynamic amount of FruitOffers.""" 30 | return modelformset_factory(FruitOffer, form=OfferForm, extra=1) 31 | 32 | 33 | def get_purchases_formset(n_forms=0): 34 | """ 35 | Helper method that returns a Django formset for a dynamic amount of Purchases. Initially `n_forms` empty 36 | forms are shown. 37 | """ 38 | return modelformset_factory(Purchase, fields=('amount', 'fruit'), extra=n_forms) 39 | 40 | 41 | class CreateOffersPage(Page): 42 | """ 43 | First page: Page where sellers can define their offers. 44 | """ 45 | 46 | def vars_for_template(self): 47 | """ 48 | Define the forms that will be shown. 49 | """ 50 | 51 | OffersFormSet = get_offers_formset() 52 | 53 | if self.player.role() == 'seller': 54 | # For the seller, show a formset with fruit offers. If this is not the first round, already defined 55 | # fruit offers will be loaded. 56 | # The seller can choose to buy fruit from the wholesale market at fixed prices defined in 57 | # `FruitOffer.PURCHASE_PRICES`. 58 | 59 | player_offers_qs = FruitOffer.objects.filter(seller=self.player) # load existing offers for this player 60 | 61 | return { 62 | 'purchase_prices': FruitOffer.PURCHASE_PRICES, 63 | 'offers_formset': OffersFormSet(queryset=player_offers_qs), 64 | } 65 | else: # nothing to do for customers at this page 66 | return {} 67 | 68 | def before_next_page(self): 69 | """ 70 | Implement custom form handling for formsets. 71 | """ 72 | if self.player.role() == 'buyer': # nothing to do for customers at this page 73 | return 74 | 75 | # get the formset 76 | OffersFormSet = get_offers_formset() 77 | 78 | # fill it with the submitted data 79 | offers_formset = OffersFormSet(self.form.data) 80 | 81 | # iterate through the forms in the formset 82 | offers_objs = [] # stores *new* FruitOffer objects 83 | cost = 0 # total cost for the seller buying fruits that she or he can offer on the market 84 | for form_idx, form in enumerate(offers_formset.forms): 85 | if form.is_valid(): 86 | if self.subsession.round_number > 1 and form.cleaned_data.get('id'): # update an existing offer 87 | # set the new amount and price for an existing offer 88 | new_amount = form.cleaned_data['amount'] 89 | new_price = form.cleaned_data['price'] 90 | changed_offer = form.cleaned_data['id'] 91 | changed_offer.amount = form.cleaned_data['old_amount'] + new_amount # increment existing amount 92 | changed_offer.price = new_price 93 | 94 | if changed_offer.amount > 0: 95 | changed_offer.save() # save existing offer (update) 96 | cost += new_amount * FruitOffer.PURCHASE_PRICES[changed_offer.kind] # update total cost 97 | else: 98 | changed_offer.delete() # offers that dropped to amount zero will be removed 99 | elif form.cleaned_data.get('amount', 0) > 0: # create new offer 100 | # create a new FruitOffer object with the submitted data and set the seller to the current player 101 | submitted_data = {k: v for k, v in form.cleaned_data.items() if k != 'old_amount'} 102 | offer = FruitOffer(**submitted_data, seller=self.player) 103 | cost += offer.amount * FruitOffer.PURCHASE_PRICES[offer.kind] # update total cost 104 | offers_objs.append(offer) 105 | 106 | else: # invalid forms are not handled well so far -> we just ignore them 107 | print('player %d: invalid form #%d' % (self.player.id_in_group, form_idx)) 108 | 109 | # store the new offers in the DB (insert new data) 110 | if offers_objs: 111 | FruitOffer.objects.bulk_create(offers_objs) 112 | 113 | # update seller's balance 114 | self.player.balance -= cost 115 | 116 | 117 | class PurchasePage(Page): 118 | """ 119 | Second page: Page where customers can buy offered fruit. 120 | """ 121 | 122 | def vars_for_template(self): 123 | """ 124 | Define the forms that will be shown. 125 | """ 126 | 127 | if self.player.role() == 'buyer': 128 | # load the all offers for this round (by filtering for same subsession) 129 | # use `select_related` for quicker data retrieval 130 | offers = FruitOffer.objects.select_related('seller__subsession', 'seller__participant').\ 131 | filter(seller__subsession=self.player.subsession).\ 132 | order_by('seller', 'kind') 133 | 134 | # get a formset for purchases, one for each available offer 135 | PurchasesFormSet = get_purchases_formset(len(offers)) 136 | 137 | # fill the formset with data from the offers 138 | purchases_formset = PurchasesFormSet(initial=[{'amount': 0, 'fruit': offer} 139 | for offer in offers], 140 | queryset=Purchase.objects.none()) 141 | 142 | return { 143 | 'purchases_formset': purchases_formset, # formset as whole 144 | 'offers_with_forms': zip(offers, purchases_formset), # formset where each form is combined with the 145 | # related offer 146 | } 147 | else: # for sellers, only show the fruit she/he currently offers 148 | offers = FruitOffer.objects.filter(seller=self.player).order_by('kind') 149 | 150 | return { 151 | 'sellers_offers': offers 152 | } 153 | 154 | def before_next_page(self): 155 | """ 156 | Implement custom form handling for formsets. 157 | """ 158 | 159 | if self.player.role() == 'seller': # nothing to do for sellers at this page 160 | return 161 | 162 | # get the formset for purchases 163 | PurchasesFormSet = get_purchases_formset() 164 | 165 | # pass it the submitted data 166 | purchases_formset = PurchasesFormSet(self.form.data) 167 | 168 | # iterate through the forms in the formset 169 | purchase_objs = [] # stores new Purchase objects 170 | total_price = 0 # total cost for the customer 171 | for form_idx, form in enumerate(purchases_formset.forms): 172 | # handle valid forms where at least 1 item was bought 173 | if form.is_valid() and form.cleaned_data['amount'] > 0: 174 | # create a new Purchase object with the submitted data and set the buyer to the current player 175 | purchase = Purchase(**form.cleaned_data, buyer=self.player) 176 | #purchase.fruit.amount -= purchase.amount # decrease amount of available fruit (nope - this will 177 | # be done below in the Results page) 178 | prod = purchase.amount * purchase.fruit.price # total price for this offer 179 | purchase.fruit.seller.balance += prod # increase seller's balance 180 | total_price += prod # add to total price 181 | 182 | #purchase.fruit.save() # seller will be saved automatically (as it is a Player object) 183 | purchase_objs.append(purchase) 184 | 185 | # store the purchases in the DB 186 | Purchase.objects.bulk_create(purchase_objs) 187 | 188 | # update buyer's balance 189 | self.player.balance -= total_price 190 | 191 | 192 | class Results(Page): 193 | """ 194 | Third page: Summarize results of this round. 195 | """ 196 | 197 | def vars_for_template(self): 198 | """ 199 | Transactions and change in balance for both player roles. 200 | """ 201 | 202 | if self.player.role() == 'buyer': 203 | # for a customer, load all purchases she or he made in this round 204 | transactions = Purchase.objects.select_related('buyer__subsession', 'buyer__participant', 205 | 'fruit__seller__participant'). \ 206 | filter(buyer=self.player).\ 207 | order_by('fruit__seller', 'fruit__kind') 208 | else: 209 | # for a seller, load all sales she or he made in this round 210 | transactions = Purchase.objects.select_related('buyer__participant', 'fruit__seller'). \ 211 | filter(fruit__seller=self.player).\ 212 | order_by('buyer', 'fruit__kind') 213 | 214 | return { 215 | 'transactions': transactions, 216 | 'balance_change': sum([t.amount * t.fruit.price for t in transactions]) 217 | } 218 | 219 | def before_next_page(self): 220 | """ 221 | Update the balance for this round 222 | """ 223 | 224 | if self.subsession.round_number < Constants.num_rounds: 225 | # get player instance for next round 226 | next_round = self.subsession.round_number + 1 227 | next_player = self.player.in_round(next_round) 228 | 229 | # set the current balance as the new initial balance for the next round 230 | next_player.initial_balance = self.player.balance 231 | next_player.balance = next_player.initial_balance 232 | 233 | if self.player.role() == 'seller': 234 | # copy sellers' offers to the new round 235 | 236 | # fetch all sales from this seller in this round 237 | sales_from_seller = list(Purchase.objects.select_related('fruit').filter(fruit__seller=self.player)) 238 | 239 | # fetch all offers from this seller in this round and iterate through them 240 | for o in FruitOffer.objects.filter(seller=self.player): 241 | # find the related purchase if fruit from this offer was bought 242 | related_purchase_ind = None 243 | for p_i, purchase in enumerate(sales_from_seller): 244 | if purchase.fruit.pk == o.pk: 245 | related_purchase_ind = p_i 246 | break 247 | 248 | # set primary key to None in order to store as new FruitOffer object 249 | o.pk = None 250 | 251 | if related_purchase_ind is not None: 252 | # decrease the amount of available fruit for this offer 253 | rel_purchase = sales_from_seller.pop(related_purchase_ind) 254 | o.amount -= rel_purchase.amount 255 | 256 | # set the seller as the player instance for the next round 257 | o.seller = next_player 258 | 259 | # store to database 260 | o.save() 261 | 262 | 263 | # define the page sequence. interleave with WaitPages because purchases can only be made after offers were defined and 264 | # results can only be shown after purchases were made. 265 | page_sequence = [ 266 | CreateOffersPage, 267 | NormalWaitPage, 268 | PurchasePage, 269 | NormalWaitPage, 270 | Results, 271 | NormalWaitPage 272 | ] 273 | -------------------------------------------------------------------------------- /otreeutils_example2/models.py: -------------------------------------------------------------------------------- 1 | from otree.api import ( 2 | models, widgets, BaseConstants, BaseSubsession, BaseGroup, BasePlayer, 3 | Currency as c, currency_range 4 | ) 5 | 6 | from otreeutils.surveys import create_player_model_for_survey, generate_likert_field, generate_likert_table 7 | from django.forms.widgets import Select 8 | 9 | 10 | author = 'Markus Konrad' 11 | 12 | doc = """ 13 | Example 2 for usage of the otreeutils package. 14 | """ 15 | 16 | 17 | class Constants(BaseConstants): 18 | name_in_url = 'otreeutils_example2' 19 | players_per_group = None 20 | num_rounds = 1 21 | 22 | 23 | class Subsession(BaseSubsession): 24 | def creating_session(self): 25 | for i, p in enumerate(self.get_players()): 26 | p.treatment = (i % 2) + 1 # every second player gets treatment 2 27 | 28 | 29 | class Group(BaseGroup): 30 | pass 31 | 32 | 33 | # some pre-defined choices 34 | 35 | GENDER_CHOICES = ( 36 | ('female', 'Female'), 37 | ('male', 'Male'), 38 | ('other', 'Other'), 39 | ('no_answer', 'Prefer not to answer'), 40 | ) 41 | 42 | YESNO_CHOICES = ( 43 | ('yes', 'Yes'), 44 | ('no', 'No'), 45 | ) 46 | 47 | EBAY_ITEMS_PER_WEEK = ( 48 | ('<5', 'less than 5'), 49 | ('5-10', 'between 5 and 10'), 50 | ('>10', 'more than 10'), 51 | ) 52 | 53 | # define a Likert 5-point scale with its labels 54 | 55 | likert_5_labels = ( 56 | 'Strongly disagree', 57 | 'Disagree', 58 | 'Neither agree nor disagree', 59 | 'Agree', 60 | 'Strongly agree' 61 | ) 62 | 63 | likert_5_labels_html = ( 64 | 'Strongly disagree', 65 | 'Disagree', 66 | 'Neither agree nor disagree', 67 | 'Agree', 68 | 'Strongly agree' 69 | ) 70 | 71 | 72 | likert_5point_field = generate_likert_field(likert_5_labels) 73 | likert_5point_field_html = generate_likert_field(likert_5_labels_html, html_labels=True) 74 | likert_5point_field_centered = generate_likert_field(likert_5_labels, choices_values=-2) 75 | likert_5point_values = ['strong_dis', 'dis', 'neutral', 'agr', 'strong_agr'] 76 | likert_5point_field_labeled_values = generate_likert_field(likert_5_labels, 77 | choices_values=likert_5point_values, 78 | widget=Select) # also use Select widget from Django for dropdown menu 79 | 80 | 81 | # define survey questions per page 82 | # for each page define a page title and a list of questions 83 | # the questions have a field name, a question text (input label), and a field type (model field class) 84 | SURVEY_DEFINITIONS = { 85 | 'SurveyPage1': { 86 | 'page_title': 'Survey Questions - Page 1 - Simple questions and inputs', 87 | 'survey_fields': [ 88 | ('q_age', { # field name (which will also end up in your "Player" class and hence in your output data) 89 | 'text': 'How old are you?', # survey question 90 | 'field': models.PositiveIntegerField(min=18, max=100), # the same as in normal oTree model field definitions 91 | }), 92 | ('q_gender', { 93 | 'text': 'Please tell us your gender.', 94 | 'field': models.CharField(choices=GENDER_CHOICES), 95 | }), 96 | ] 97 | }, 98 | 'SurveyPage2': { 99 | 'page_title': 'Survey Questions - Page 2 - Likert 5-point scale', 100 | 'survey_fields': [ 101 | ('q_otree_surveys', { # most of the time, you'd add a "help_text" for a Likert scale question. You can use HTML: 102 | 'help_text': """ 103 |

    Consider this quote:

    104 |
    105 | "oTree is great to make surveys, too." 106 |
    107 |

    What do you think?

    108 | """, 109 | 'field': likert_5point_field(), # don't forget the parentheses at the end! 110 | }), 111 | ('q_just_likert', { 112 | 'label': 'Another Likert scale input:', # optional, no HTML 113 | 'field': likert_5point_field(), # don't forget the parentheses at the end! 114 | }), 115 | ('q_likert_htmllabels', { 116 | 'help_text': 'Another Likert scale input, this time the labels use HTML for bold text:
    ', # HTML for line break 117 | 'field': likert_5point_field_html(), # don't forget the parentheses at the end! 118 | }), 119 | ('q_likert_centered', { 120 | 'help_text': 'Likert scale input that translates to values [-2, -1, 0, 1, 2]:
    ', # HTML for line break 121 | 'field': likert_5point_field_centered(), 122 | }), 123 | ('q_likert_labeled', { 124 | 'label': 'Likert scale input that translates to custom labeled values and as dropdown selection:', 125 | 'field': likert_5point_field_labeled_values(), 126 | }), 127 | ] 128 | }, 129 | 'SurveyPage3': { 130 | 'page_title': 'Survey Questions - Page 3 - Several forms', 131 | 'survey_fields': [ # you can also split questions into several forms for better CSS styling 132 | { # you need to provide a dict then. you can add more keys to the dict which are then available in the template 133 | 'form_name': 'first_form', # optional, can be used for CSS styling 134 | 'fields': [ 135 | ('q_student', { 136 | 'text': 'Are you a student?', 137 | 'field': models.CharField(choices=YESNO_CHOICES), 138 | }), 139 | ('q_field_of_study', { 140 | 'text': 'If so, in which field of study?', 141 | 'field': models.CharField(blank=True), 142 | }), 143 | ] 144 | }, 145 | { 146 | 'form_name': 'second_form', # optional, can be used for CSS styling 147 | 'fields': [ 148 | ('q_otree_years', { 149 | 'text': 'For how many years do you use oTree?', 150 | 'help_text': 'This is a help text.', 151 | 'help_text_below': True, 152 | 'field': models.PositiveIntegerField(min=0, max=10), 153 | }) 154 | ] 155 | }, 156 | ] 157 | }, 158 | 'SurveyPage4': { 159 | 'page_title': 'Survey Questions - Page 4 - Likert scale table', 160 | 'survey_fields': [ 161 | # create a table of Likert scale choices 162 | # we use the same 5-point scale a before and specify four rows for the table, 163 | # each with a tuple (field name, label) 164 | generate_likert_table(likert_5_labels, 165 | [ 166 | ('q_pizza_tasty', 'Tasty'), 167 | ('q_pizza_spicy', 'Spicy'), 168 | ('q_pizza_cold', 'Too cold
    or even frozen'), 169 | ('q_pizza_satiable', 'Satiable'), 170 | ], 171 | form_help_initial='

    How was your latest Pizza?

    ', # HTML to be placed on top of form 172 | form_help_final='

    Thank you!

    ', # HTML to be placed below form 173 | table_row_header_width_pct=15, # width of row header (first column) in percent. default: 25 174 | table_rows_randomize=True, # randomize order of displayed rows 175 | ), 176 | # create a second Likert scale table 177 | generate_likert_table(likert_5_labels, 178 | [ 179 | ('q_hotdog_tasty', 'Tasty'), 180 | ('q_hotdog_spicy', 'Spicy'), 181 | ], 182 | form_help_initial='

    How was your latest hot dog?

    ', # HTML to be placed on top of form 183 | table_row_header_width_pct=15, # width of row header (first column) in percent. default: 25 184 | likert_scale_opts={ # customize `generate_likert_field()`: set string values for choices instead of range 1 .. 5 185 | 'choices_values': [ 186 | 'strong_dis', 'dis', 'neutral', 'agr', 'strong_agr' 187 | ] 188 | } 189 | ) 190 | ] 191 | }, 192 | 'SurveyPage5': { 193 | 'page_title': 'Survey Questions - Page 5 - Forms depending on other variable', 194 | 'survey_fields': [ # we define two forms here ... 195 | { # ... this one is shown when player.treatment == 1 ... 196 | 'form_name': 'treatment_1_form', 197 | 'fields': [ 198 | ('q_treatment_1', { 199 | 'text': 'This is a question for treatment 1: Do you feel tired?', 200 | 'field': models.CharField(choices=YESNO_CHOICES, blank=True), 201 | }), 202 | ] 203 | }, 204 | { # ... this one is shown when player.treatment == 2 ... 205 | 'form_name': 'treatment_2_form', # optional, can be used for CSS styling 206 | 'fields': [ 207 | ('q_treatment_2', { 208 | 'text': "This is a question for treatment 2: Don't you feel tired?", 209 | 'field': models.CharField(choices=YESNO_CHOICES, blank=True), 210 | }), 211 | ] 212 | }, 213 | ] 214 | }, 215 | 'SurveyPage6': { 216 | 'page_title': 'Survey Questions - Page 6 - Conditional fields and widget adjustments', 217 | 'form_help_initial': """ 218 |

    Conditional fields can be made with the condition_javascript parameter, 219 | widget adjustments like custom CSS styles can be controlled via widget_attrs.

    """, 220 | 'survey_fields': [ 221 | ('q_uses_ebay', { 222 | 'text': 'Do you sell things on eBay?', 223 | 'field': models.CharField(choices=YESNO_CHOICES), 224 | }), 225 | ('q_ebay_member_years', { 226 | 'text': 'For how many years are you an eBay member?', 227 | 'field': models.IntegerField(min=1, blank=True, default=None), 228 | 'input_suffix': 'years', # display suffix "years" after input box 229 | 'widget_attrs': {'style': 'display:inline'}, # adjust widget style 230 | # set a JavaScript condition. if it evaluates to true (here: if "uses ebay" is set to "yes"), 231 | # this input is shown: 232 | 'condition_javascript': '$("#id_q_uses_ebay").val() === "yes"' 233 | }), 234 | ('q_ebay_sales_per_week', { 235 | 'text': 'How many items do you sell on eBay per week?', 236 | 'field': models.CharField(choices=EBAY_ITEMS_PER_WEEK, blank=True, default=None), 237 | # set a JavaScript condition. if it evaluates to true (here: if "uses ebay" is set to "yes"), 238 | # this input is shown: 239 | 'condition_javascript': '$("#id_q_uses_ebay").val() === "yes"' 240 | }), 241 | ] 242 | }, 243 | 'SurveyPage7': { 244 | 'page_title': 'Survey Questions - Page 7 - Random data input for quick debugging', 245 | 'survey_fields': [ 246 | # similar to page 4 247 | generate_likert_table(likert_5_labels, 248 | [ 249 | ('q_weather_cold', "It's too cold"), 250 | ('q_weather_hot', "It's too hot"), 251 | ('q_weather_rainy', "It's too rainy"), 252 | ], 253 | form_help_initial=""" 254 |

    On this page, the form is filled in randomly if you run the experiment in debug mode (i.e. with 255 | otree devserver or otree runserver so that APPS_DEBUG is True 256 | — see settings.py).

    257 |

    This feature is enabled for this page in pages.py like this:

    258 | 259 |
    class SurveyPage7(SurveyPage):
    260 |     debug_fill_forms_randomly = True
    261 | 
    262 | 263 |

    264 | 265 |

    What do you think about the weather?

    266 | """, 267 | form_help_final='

     

    ', 268 | form_name='likert_table' 269 | ), 270 | { # if you use a likert table *and* other questions on the same page, you have to wrap the other questions 271 | # in a extra "sub-form", i.e. an extra dict with "fields" list 272 | 'form_name': 'other_questions', # optional, can be used for CSS styling 273 | 'fields': [ 274 | ('q_monthly_income', { 275 | 'text': "What's your monthly income?", 276 | 'field': models.CurrencyField(min=0) 277 | }), 278 | ('q_num_siblings', { 279 | 'text': "How many siblings do you have?", 280 | 'field': models.IntegerField(min=0, max=20), 281 | }), 282 | ('q_comment', { 283 | 'text': "Please give us feedback on the experiment:", 284 | 'field': models.LongStringField(max_length=500) 285 | }), 286 | ] 287 | } 288 | ] 289 | }, 290 | } 291 | 292 | # now dynamically create the Player class from the survey definitions 293 | # we can also pass additional (non-survey) fields via `other_fields` 294 | Player = create_player_model_for_survey('otreeutils_example2.models', SURVEY_DEFINITIONS, other_fields={ 295 | 'treatment': models.IntegerField() 296 | }) 297 | -------------------------------------------------------------------------------- /otreeutils/surveys.py: -------------------------------------------------------------------------------- 1 | """ 2 | Survey extensions that allows to define survey questions with a simple data structure and then automatically creates 3 | the necessary model fields and pages. 4 | 5 | March 2021, Markus Konrad 6 | """ 7 | 8 | from functools import partial 9 | from collections import OrderedDict 10 | 11 | from otree.api import BasePlayer, widgets, models 12 | from django import forms 13 | 14 | from .pages import ExtendedPage 15 | 16 | 17 | class RadioSelectHorizontalHTMLLabels(forms.RadioSelect): 18 | template_name = 'otreeutils/forms/radio_select_horizontal.html' 19 | 20 | 21 | def generate_likert_field(labels, widget=None, field=None, choices_values=1, html_labels=False): 22 | """ 23 | Return a function which generates a new model field with a Likert scale. By default, this generates a Likert scale 24 | between 1 and `len(labels)` with steps of 1. You can adjust the Likert scale with `choices_values`. You can either 25 | set an *integer* offset so that the Liker scale is then the range 26 | [`choices_values` .. `len(labels) + choices_values`], or you directly pass a sequence of Likert scale values as 27 | `choices_values`. 28 | 29 | If `field` is None (default), an `IntegerField` is used if the Likert scale values are integers, otherwise a 30 | `StringField` is used. Set `field` to a model field class such as `models.StringField` to force using a certain 31 | field type. 32 | 33 | Use `widget` as selection widget (default is `RadioSelectHorizontal`). Set `html_labels` to True if HTML code is 34 | used in labels (this only works with the default widget). 35 | 36 | Example with a 4-point Likert scale: 37 | 38 | ``` 39 | likert_4_field = generate_likert_field(["Strongly disagree", "Disagree", "Agree", "Strongly agree"]) 40 | 41 | class Player(BasePlayer): 42 | q1 = likert_4_field() 43 | ``` 44 | """ 45 | if not widget: 46 | widget = widgets.RadioSelectHorizontal 47 | 48 | if html_labels: 49 | widget = RadioSelectHorizontalHTMLLabels 50 | 51 | if isinstance(choices_values, int): 52 | choices_values = range(choices_values, len(labels) + choices_values) 53 | 54 | if len(choices_values) != len(labels): 55 | raise ValueError('`choices_values` must be of same length as `labels`') 56 | 57 | if field is None: 58 | if all(isinstance(v, int) for v in choices_values): 59 | field = models.IntegerField 60 | else: 61 | field = models.StringField 62 | 63 | choices = list(zip(choices_values, labels)) 64 | 65 | return partial(field, widget=widget, choices=choices) 66 | 67 | 68 | def generate_likert_table(labels, questions, form_name=None, help_texts=None, widget=None, use_likert_scale=True, 69 | likert_scale_opts=None, make_label_tag=False, **kwargs): 70 | """ 71 | Generate a table with Likert scales between 1 and `len(labels)` in each row for questions supplied with 72 | `questions` as list of tuples (field name, field label). 73 | 74 | Optionally provide `help_texts` which is a list of help texts for each question (hence must be of same length 75 | as `questions`. 76 | 77 | If `make_label_tag` is True, then each label is surrounded by a tag, otherwise it's not. 78 | Optionally set `widget` (default is `RadioSelect`). 79 | 80 | You can pass additional arguments to `generate_likert_field` via `likert_scale_opts`. You can pass additional 81 | arguments to the survey form definition via `**kwargs`. 82 | """ 83 | if not help_texts: 84 | help_texts = [''] * len(questions) 85 | 86 | if not widget: 87 | widget = widgets.RadioSelect 88 | 89 | if len(help_texts) != len(questions): 90 | raise ValueError('Number of questions must be equal to number of help texts.') 91 | 92 | if use_likert_scale: 93 | likert_scale_opts = likert_scale_opts or {} 94 | field_generator = generate_likert_field(labels, widget=widget, **likert_scale_opts) 95 | header_labels = labels 96 | else: 97 | field_generator = partial(models.StringField, choices=labels, widget=widget or widgets.RadioSelectHorizontal) 98 | header_labels = [t[1] for t in labels] 99 | 100 | fields = [] 101 | for (field_name, field_label), help_text in zip(questions, help_texts): 102 | fields.append((field_name, { 103 | 'help_text': help_text, 104 | 'label': field_label, 105 | 'make_label_tag': make_label_tag, 106 | 'field': field_generator(), 107 | })) 108 | 109 | form_def = {'form_name': form_name, 'fields': fields, 'render_type': 'table', 'header_labels': header_labels} 110 | form_def.update(dict(**kwargs)) 111 | 112 | return form_def 113 | 114 | 115 | def create_player_model_for_survey(module, survey_definitions, other_fields=None): 116 | """ 117 | Dynamically create a player model in module with survey definitions and a base player class. 118 | Parameter `survey_definitions` is either a tuple or list, where each list item is a survey definition for a 119 | single page, or a dict that maps a page class name to the page's survey definition. 120 | 121 | Each survey definition for a single page consists of list of field name, question definition tuples. 122 | Each question definition has a "field" (oTree model field class) and a "text" (field label). 123 | 124 | Returns the dynamically created player model with the respective fields (class attributes). 125 | """ 126 | if not isinstance(survey_definitions, (tuple, list, dict)): 127 | raise ValueError('`survey_definitions` must be a tuple, list or dict') 128 | 129 | if other_fields is None: 130 | other_fields = {} 131 | else: 132 | if not isinstance(other_fields, dict): 133 | raise ValueError('`other_fields` must be a dict with field name to field object mapping') 134 | 135 | # oTree doesn't allow to store a mutable attribute to any of its models, so we store values and keys as tuples 136 | if isinstance(survey_definitions, dict): 137 | survey_defs = tuple(survey_definitions.values()) 138 | survey_keys = tuple(survey_definitions.keys()) 139 | else: 140 | survey_defs = tuple(survey_definitions) 141 | survey_keys = None 142 | 143 | model_attrs = { 144 | '__module__': module, 145 | '_survey_defs': survey_defs, 146 | '_survey_def_keys': survey_keys, 147 | } 148 | 149 | # collect fields 150 | def add_field(field_name, qdef): 151 | if field_name in model_attrs: 152 | raise ValueError('duplicate field name: `%s`' % field_name) 153 | model_attrs[field_name] = qdef['field'] 154 | 155 | if isinstance(survey_definitions, dict): 156 | survey_definitions_iter = survey_definitions.values() 157 | else: 158 | survey_definitions_iter = survey_definitions 159 | 160 | for survey_page in survey_definitions_iter: 161 | for fielddef in survey_page['survey_fields']: 162 | if isinstance(fielddef, dict): 163 | for field_name, qdef in fielddef['fields']: 164 | add_field(field_name, qdef) 165 | else: 166 | add_field(*fielddef) 167 | 168 | # add optional fields 169 | model_attrs.update(other_fields) 170 | 171 | # dynamically create model 172 | model_cls = type('Player', (BasePlayer, _SurveyModelMixin), model_attrs) 173 | 174 | return model_cls 175 | 176 | 177 | class _SurveyModelMixin(object): 178 | """Little mix-in for dynamically generated survey model classes""" 179 | @classmethod 180 | def get_survey_definitions(cls): 181 | """Return survey definitions either as dict (if keys were defined) or as tuple""" 182 | if cls._survey_def_keys is None: 183 | return cls._survey_defs 184 | else: 185 | return dict(zip(cls._survey_def_keys, cls._survey_defs)) 186 | 187 | 188 | def setup_survey_pages(form_model, survey_pages): 189 | """ 190 | Helper function to set up a list of survey pages with a common form model 191 | (a dynamically generated survey model class). 192 | """ 193 | 194 | if not hasattr(form_model, 'get_survey_definitions'): 195 | raise TypeError("`form_model` doesn't implement `get_survey_definitions()`; you probably didn't correctly " 196 | "set up the model for using surveys; please refer to " 197 | "https://github.com/WZBSocialScienceCenter/otreeutils#otreeutilssurveys-module " 198 | "for help") 199 | 200 | if len(survey_pages) != len(form_model.get_survey_definitions()): 201 | raise ValueError('the number of provided survey pages in `survey_pages` is different from the number of ' 202 | 'surveys defined in the model survey definition') 203 | 204 | for i, page in enumerate(survey_pages): 205 | page.setup_survey(form_model, page.__name__, i) # call setup function with model class and page index 206 | 207 | 208 | class SurveyPage(ExtendedPage): 209 | """ 210 | Common base class for survey pages. 211 | Displays a form for the survey questions that were defined for this page. 212 | """ 213 | FORM_OPTS_DEFAULT = { 214 | 'render_type': 'standard', 215 | 'form_help_initial': '', 216 | 'form_help_final': '', 217 | # configuration options for likert tables 218 | 'table_repeat_header_each_n_rows': 0, # set to integer N > 0 to repeat the table header after every N rows 219 | 'table_row_header_width_pct': 25, # leftmost column width (table row header) in percent 220 | 'table_cols_equal_width': True, # adjust form columns so that they have equal width 221 | 'table_rows_equal_height': True, # adjust form rows so that they have equal height 222 | 'table_rows_alternate': True, # alternate form rows between "odd" and "even" CSS classes (alternates background colors) 223 | 'table_rows_highlight': True, # highlight form rows on mouse-over 224 | 'table_rows_randomize': False, # randomize form rows 225 | 'table_cells_highlight': True, # highlight form cells on mouse-over 226 | 'table_cells_clickable': True, # make form cells clickable for selection (otherwise only the small radio buttons can be clicked) 227 | } 228 | template_name = 'otreeutils/SurveyPage.html' 229 | field_labels = {} 230 | field_help_text = {} 231 | field_help_text_below = {} 232 | field_make_label_tag = {} 233 | field_input_prefix = {} 234 | field_input_suffix = {} 235 | field_widget_attrs = {} 236 | field_condition_javascript = {} 237 | field_forms = {} 238 | forms_opts = {} 239 | form_label_suffix = ':' 240 | 241 | @classmethod 242 | def setup_survey(cls, player_cls, page_name, page_idx): 243 | """Setup a survey page using model class and survey definitions for page .""" 244 | survey_all_pages = player_cls.get_survey_definitions() # this is either a tuple or a dict 245 | if isinstance(survey_all_pages, tuple): 246 | if 0 <= page_idx < len(survey_all_pages): 247 | survey_defs = survey_all_pages[page_idx] 248 | else: 249 | raise RuntimeError('there is no survey definition for page with index %d' % page_idx) 250 | else: 251 | if page_name in survey_all_pages: 252 | survey_defs = survey_all_pages[page_name] 253 | else: 254 | raise RuntimeError('there is no survey definition for page with name %s' % page_name) 255 | del survey_all_pages 256 | 257 | cls.form_model = player_cls 258 | cls.page_title = survey_defs['page_title'] 259 | cls.form_label_suffix = survey_defs.get('form_label_suffix', '') 260 | 261 | cls.field_labels = {} 262 | cls.field_help_text = {} 263 | cls.field_help_text_below = {} 264 | cls.field_input_prefix = {} 265 | cls.field_input_suffix = {} 266 | cls.field_widget_attrs = {} 267 | cls.field_condition_javascript = {} 268 | cls.field_forms = {} 269 | cls.forms_opts = {} 270 | cls.form_fields = [] 271 | 272 | def add_field(cls_, form_name, field_name, qdef): 273 | cls_.field_labels[field_name] = qdef.get('text', qdef.get('label', '')) 274 | cls_.field_help_text[field_name] = qdef.get('help_text', '') 275 | cls_.field_help_text_below[field_name] = qdef.get('help_text_below', False) 276 | cls_.field_make_label_tag[field_name] = qdef.get('make_label_tag', False) 277 | cls_.field_input_prefix[field_name] = qdef.get('input_prefix', '') 278 | cls_.field_input_suffix[field_name] = qdef.get('input_suffix', '') 279 | cls_.field_widget_attrs[field_name] = qdef.get('widget_attrs', {}) 280 | cls_.field_condition_javascript[field_name] = qdef.get('condition_javascript', '') 281 | cls_.form_fields.append(field_name) 282 | cls_.field_forms[field_name] = form_name 283 | 284 | form_idx = 0 285 | form_name = None 286 | survey_defs_form_opts = {k: v for k, v in survey_defs.items() if k.startswith('form_')} 287 | for fielddef in survey_defs['survey_fields']: 288 | form_name_default = 'form%d_%d' % (page_idx, form_idx) 289 | 290 | if isinstance(fielddef, dict): 291 | form_name = fielddef.get('form_name', None) or form_name_default 292 | if form_name in cls.forms_opts.keys(): 293 | raise ValueError('form with name `%s` already exists in survey form options definition' % form_name) 294 | cls.forms_opts[form_name] = cls.FORM_OPTS_DEFAULT.copy() 295 | cls.forms_opts[form_name].update({k: v for k, v in fielddef.items() 296 | if k not in ('fields', 'form_name')}) 297 | 298 | for field_name, qdef in fielddef['fields']: 299 | add_field(cls, form_name, field_name, qdef) 300 | 301 | form_idx += 1 302 | else: 303 | if form_name is None: 304 | form_name = form_name_default 305 | if form_name in cls.forms_opts.keys(): 306 | raise ValueError('form with name `%s` already exists in survey form options definition' 307 | % form_name) 308 | 309 | cls.forms_opts[form_name] = cls.FORM_OPTS_DEFAULT.copy() 310 | cls.forms_opts[form_name].update(survey_defs_form_opts) 311 | add_field(cls, form_name, *fielddef) 312 | 313 | def get_context_data(self, **kwargs): 314 | ctx = super(SurveyPage, self).get_context_data(**kwargs) 315 | 316 | form = kwargs['form'] 317 | form.label_suffix = self.form_label_suffix 318 | 319 | survey_forms = OrderedDict() 320 | for field_name, field in form.fields.items(): 321 | if field_name in self.form_fields: 322 | form_name = self.field_forms[field_name] 323 | 324 | field.label = self.field_labels[field_name] 325 | field.help_text = { # abusing the help text attribute here for arbitrary field options 326 | 'help_text': self.field_help_text[field_name], 327 | 'help_text_below': self.field_help_text_below[field_name], 328 | 'make_label_tag': self.field_make_label_tag[field_name], 329 | 'input_prefix': self.field_input_prefix[field_name], 330 | 'input_suffix': self.field_input_suffix[field_name], 331 | 'condition_javascript': self.field_condition_javascript[field_name], 332 | } 333 | 334 | field.widget.attrs.update(self.field_widget_attrs[field_name]) 335 | 336 | if form_name not in survey_forms: 337 | survey_forms[form_name] = {'fields': [], 'form_opts': self.forms_opts.get(form_name, {})} 338 | 339 | survey_forms[form_name]['fields'].append(field_name) 340 | 341 | ctx.update({ 342 | 'base_form': form, 343 | 'survey_forms': survey_forms, 344 | }) 345 | 346 | return ctx 347 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # otreeutils 2 | 3 | March 2021, Markus Konrad / / [Berlin Social Science Center](https://wzb.eu) 4 | 5 | **This project is currently not maintained.** 6 | 7 | ## A package with common oTree utilities 8 | 9 | This repository contains the package `otreeutils`. It features a set of common helper / utility functions and classes often needed when developing experiments with [oTree](http://www.otree.org/). So far, this covers the following use cases: 10 | 11 | * Easier creation of surveys: 12 | * define all survey questions in a single data structure, let `otreeutils` create the required `Player` fields 13 | * create a table of Likert scale inputs ("Likert matrix") 14 | * create single Likert scale fields from given labels 15 | * easy survey forms styling via CSS due to cleanly structured HTML output 16 | * make survey forms with conditional inputs 17 | * Extensions to oTree's admin interface for [using custom data models](https://datascience.blog.wzb.eu/2016/10/31/using-custom-data-models-in-otree/), which include: 18 | * Live session data view shows data from custom models 19 | * Export page allows download of complete data with data from custom models 20 | * Export page allows download in nested JSON format 21 | * Displaying and validating understanding questions 22 | * Displaying warnings to participants when a timeout occurs on a page (no automatic form submission after timeout) 23 | * More convenient development process by optional automatic fill-in of forms (saves you from clicking through many inputs during development) 24 | * Setting custom URLs for pages (instead of default: the page's class name) 25 | 26 | This screenshot shows an example survey page with a Likert matrix: 27 | 28 | ![survey page with Likert matrix](img/likerttable.png) 29 | 30 | The package is [available on PyPI](https://pypi.org/project/otreeutils/) and can be installed via `pip install otreeutils`. 31 | 32 | **Compatibility note:** This package is compatible with oTree v3.3.x. If you have an older oTree version, check out the [CHANGES](CHANGES.md) file to see which version is compatible with your oTree version. You can install the exact version then with `pip install otreeutils==x.y.z` where `x.y.z` denotes an otreeutils version number. 33 | 34 | ## Citation 35 | 36 | If you used *otreeutils* in your published research, please cite it as follows: 37 | 38 | [Konrad, M. (2018). oTree: Implementing experiments with dynamically determined data quantity. *Journal of Behavioral and Experimental Finance.*](https://doi.org/10.1016/j.jbef.2018.10.006) 39 | 40 | 41 | ## Examples 42 | 43 | The repository contains three example apps which show the respective features and how they can be used in own experiments: 44 | 45 | * `otreeutils_example1` -- Understanding questions and timeout warnings 46 | * `otreeutils_example2` -- Surveys 47 | * `otreeutils_example3_market` -- Market: An example showing custom data models to collect a dynamically determined data quantity. Shows how otreeutils' admin extensions allow live data view and data export for these requirements. Companion code for [Konrad 2018](https://doi.org/10.1016/j.jbef.2018.10.006). See [its dedicated README page](https://github.com/WZBSocialScienceCenter/otreeutils/tree/master/otreeutils_example3_market). 48 | 49 | ## Limitations 50 | 51 | The admin interface extensions have still a limitation: Data export with all data from custom models is only possible with per app download option, not with the "all apps" option. 52 | 53 | ## Requirements 54 | 55 | This package requires oTree v3.3.x and optionally [pandas](http://pandas.pydata.org/). The requirements will be installed along with otreeutils when using `pip` (see below). 56 | 57 | ## Installation and setup 58 | 59 | In order to use otreeutils in your experiment implementation, you only need to do the following things: 60 | 61 | 1. Either install the package from [PyPI](https://pypi.python.org/pypi/otreeutils) via *pip* (`pip install otreeutils`) or download/clone this github repository and copy the `otreeutils` folder to your oTree experiment directory. 62 | 2. Edit your `settings.py` so that you add "otreeutils" to your `INSTALLED_APPS` list. **Don't forget this, otherwise the required templates and static files cannot be loaded correctly!** 63 | 64 | 65 | ## API overview 66 | 67 | It's best to have a look at the (documented) examples to see how to use the API. 68 | 69 | ### `otreeutils.pages` module 70 | 71 | #### `ExtendedPage` class 72 | 73 | A common page extension to oTree's default `Page` class. 74 | All other page classes in `otreeutils` extend this class. Allows to define a custom page URL via `custom_name_in_url`, timeout warnings, a page title and provides a template variable `debug` with which you can toggle debug code in your templates / JavaScript parts. 75 | 76 | The template variable `debug` (integer – 0 or 1) is toggled using an additional `APPS_DEBUG` variable in `settings.py`. See the `settings.py` of this repository. This is quite useful for example in order to fill in the correct questions on a page with understanding questions automatically in a debug session (so that it is easier to click through the pages). 77 | 78 | There is also a page variable `debug_fill_forms_randomly`, which can be set for any page derived from the `ExtendedPage` class (i.e. also for survey pages -- see below). If you set this variable to `True`, then all form inputs on the page are automatically filled in with random values once you visit the page. This happens when you run the experiment in "debug mode", i.e. when `APPS_DEBUG` is set to `True`. By default, `debug_fill_forms_randomly` is set to `False`. You can enable this feature for a given page like this: 79 | 80 | ```python 81 | from otreeutils.pages import ExtendedPage 82 | 83 | class MyPage(ExtendedPage): 84 | debug_fill_forms_randomly = True 85 | ``` 86 | 87 | This saves time when you click through an experiment with many complex forms. 88 | 89 | #### `UnderstandingQuestionsPage` class 90 | 91 | Base class to implement understanding questions. A participant must complete all questions in order to proceed. You can display hints. Use it as follows: 92 | 93 | ```python 94 | from otreeutils.pages import UnderstandingQuestionsPage 95 | 96 | class SomeUnderstandingQuestions(UnderstandingQuestionsPage): 97 | page_title = 'Set a page title' 98 | questions = [ 99 | { 100 | 'question': 'What is π?', 101 | 'options': [1.2345, 3.14159], 102 | 'correct': 3.14159, 103 | 'hint': 'You can have a look at Wikipedia!' # this is optional 104 | }, 105 | # ... 106 | ] 107 | ``` 108 | 109 | By default, the performance of the participant is not recorded, but you can optionally provide a `form_model` and set a field in `form_field_n_wrong_attempts` which defines in which field the number of wrong attempts is written. 110 | 111 | If you set `APPS_DEBUG` to `True`, the correct answers will already be filled in order to skip swiftly through pages during development. 112 | 113 | 114 | ### `otreeutils.surveys` module 115 | 116 | #### `create_player_model_for_survey` function 117 | 118 | This function allows to dynamically create a `Player` model class for a survey. It can be used as follows in `models.py`. 119 | 120 | At first you define your questions per page in a survey definitions data structure, for example like this: 121 | 122 | ```python 123 | from otreeutils.surveys import create_player_model_for_survey 124 | 125 | 126 | GENDER_CHOICES = ( 127 | ('female', 'Female'), 128 | ('male', 'Male'), 129 | ('no_answer', 'Prefer not to answer'), 130 | ) 131 | 132 | 133 | SURVEY_DEFINITIONS = { 134 | 'SurveyPage1': { 135 | 'page_title': 'Survey Questions - Page 1', 136 | 'survey_fields': [ 137 | ('q1_a', { # field name (which will also end up in your "Player" class and hence in your output data) 138 | 'text': 'How old are you?', # survey question 139 | 'field': models.PositiveIntegerField(min=18, max=100), # the same as in normal oTree model field definitions 140 | }), 141 | ('q1_b', { 142 | 'text': 'Please tell us your gender.', 143 | 'field': models.CharField(choices=GENDER_CHOICES), 144 | }), 145 | # ... more questions 146 | ] 147 | }, 148 | # ... more pages 149 | } 150 | ``` 151 | 152 | Note how `SURVEY_DEFINITIONS` is a dictionary which maps pages to the survey questions that should appear on each page. We will later create a `SurveyPage1` page class in `pages.py`. 153 | 154 | Now you create the `Player` class by passing the name of the module for which it will be created. This should be the `models` module of your app, so in your case this is `'survey.models'` if your app is named `survey`. The second parameter is the survey definitions that we just created: 155 | 156 | ```python 157 | Player = create_player_model_for_survey('otreeutils_example2.models', SURVEY_DEFINITIONS) 158 | ``` 159 | 160 | The attributes (model fields, etc.) will be automatically created. When you run `otree resetdb`, you will see that the fields `q1_a`, `q1_b`, etc. will be generated in the database. 161 | 162 | You may also add extra (non-survey) fields to your `Player` class, by passing a dict to the optional `other_fields` parameter: 163 | 164 | ```python 165 | Player = create_player_model_for_survey('otreeutils_example2.models', SURVEY_DEFINITIONS, other_fields={ 166 | 'treatment': models.IntegerField() 167 | }) 168 | ``` 169 | 170 | ##### Likert score inputs via `generate_likert_field` and `generate_likert_table` functions 171 | 172 | The function `generate_likert_field` allows you to easily generate fields for a given Likert scale and can be used inside a survey definitions data structure: 173 | 174 | ```python 175 | from otreeutils.surveys import generate_likert_field 176 | 177 | likert_5_labels = ( 178 | 'Strongly disagree', # value: 1 179 | 'Disagree', # value: 2 180 | 'Neither agree nor disagree', # ... 181 | 'Agree', 182 | 'Strongly agree' # value: 5 183 | ) 184 | 185 | likert_5point_field = generate_likert_field(likert_5_labels) 186 | ``` 187 | 188 | The object `likert_5point_field` is now a *function* to generate new fields of the specified Likert scale: 189 | 190 | ```python 191 | # ... 192 | 193 | SURVEY_DEFINITIONS = { 194 | 'SurveyPage2': { 195 | 'page_title': 'A Likert 5-point scale example', 196 | 'survey_fields': [ 197 | ('q_otree_surveys', { # most of the time, you'd add a "help_text" for a Likert scale question. You can use HTML: 198 | 'help_text': """ 199 |

    Consider this quote:

    200 |
    201 | "oTree is great to make surveys, too." 202 |
    203 |

    What do you think?

    204 | """, 205 | 'field': likert_5point_field(), # don't forget the parentheses at the end! 206 | }), 207 | ('q_just_likert', { 208 | 'label': 'Another Likert scale input:', # optional, no HTML 209 | 'field': likert_5point_field(), # don't forget the parentheses at the end! 210 | }), 211 | ] 212 | }, 213 | # ... more pages 214 | } 215 | ``` 216 | 217 | The function `generate_likert_table` allows you to easily generate a table of Likert scale inputs like a matrix with the Likert scale increments in the columns and your questions in the rows: 218 | 219 | ```python 220 | # ... 221 | 222 | SURVEY_DEFINITIONS = { 223 | 'SurveyPage3': { 224 | 'page_title': 'A Likert scale table example', 225 | 'survey_fields': [ 226 | # create a table of Likert scale choices 227 | # we use the same 5-point scale a before and specify four rows for the table, 228 | # each with a tuple (field name, label) 229 | generate_likert_table(likert_5_labels, 230 | [ 231 | ('q_pizza_tasty', 'Tasty'), 232 | ('q_pizza_spicy', 'Spicy'), 233 | ('q_pizza_cold', 'Too cold'), 234 | ('q_pizza_satiable', 'Satiable'), 235 | ], 236 | form_help_initial='

    How was your latest Pizza?

    ', # HTML to be placed on top of form 237 | form_help_final='

    Thank you!

    ' # HTML to be placed below form 238 | ) 239 | ] 240 | }, 241 | # ... more pages 242 | } 243 | ``` 244 | 245 | There are several additional parameters that you can pass to `generate_likert_table()` which will control the display and behavior of the table: 246 | 247 | - `table_repeat_header_each_n_rows=`: set to integer N > 0 to repeat the table header after every N rows 248 | - `table_cols_equal_width=`: adjust form columns so that they have equal width 249 | - `table_row_header_width_pct=`: if form columns should have equal width, this specifies the width of the first column (the table row header) in percent (default: 25) 250 | - `table_rows_equal_height=`: adjust form rows so that they have equal height 251 | - `table_rows_alternate=`: alternate form rows between "odd" and "even" CSS classes (alternates background colors) 252 | - `table_rows_randomize=`: randomize form rows 253 | - `table_rows_highlight=`: highlight form rows on mouse-over 254 | - `table_cells_highlight=`: highlight form cells on mouse-over 255 | - `table_cells_clickable=`: make form cells clickable for selection (otherwise only the small radio buttons can be clicked) 256 | 257 | #### More options for surveys 258 | 259 | To implement advanced features such as conditional input display, have a look at the example app `otreeutils_example2`. 260 | 261 | #### `SurveyPage` class 262 | 263 | You can then create the survey pages which will contain the questions for the respective pages as defined before in `SURVEY_DEFINITIONS`: 264 | 265 | ```python 266 | # (in pages.py) 267 | 268 | from otreeutils.surveys import SurveyPage, setup_survey_pages 269 | 270 | # Create the survey page classes; their names must correspond to the names used in the survey definition 271 | 272 | class SurveyPage1(SurveyPage): 273 | pass 274 | class SurveyPage2(SurveyPage): 275 | pass 276 | # more pages ... 277 | 278 | # Create a list of survey pages. 279 | 280 | survey_pages = [ 281 | SurveyPage1, 282 | SurveyPage2, 283 | # more pages ... 284 | ] 285 | ``` 286 | 287 | Since each `SurveyPage` is derived from the `ExtendedPage` class, you can also enable the automatic fill-in feature. This means that all form inputs on the page are automatically filled in with random values once you visit the page. That happens when you run the experiment in "debug mode", i.e. when `APPS_DEBUG` is set to `True`. By default, `debug_fill_forms_randomly` is set to `False`. You can enable this feature for a given survey page like this: 288 | 289 | ```python 290 | class SurveyPage3(SurveyPage): 291 | debug_fill_forms_randomly = True 292 | ``` 293 | 294 | This saves time when you click through an experiment with many survey fields. 295 | 296 | #### `setup_survey_pages` function 297 | 298 | Now all survey pages need to be set up. The `Player` class will be passed to all survey pages and the questions for each page will be set according to their order. 299 | 300 | ```python 301 | # Common setup for all pages (will set the questions per page) 302 | setup_survey_pages(models.Player, survey_pages) 303 | ``` 304 | 305 | Finally, we can set the `page_sequence` in order to use our survey pages: 306 | 307 | ```python 308 | page_sequence = [ 309 | SurveyIntro, # define some pages that come before the survey 310 | # ... 311 | ] 312 | 313 | # add the survey pages to the page sequence list 314 | page_sequence.extend(survey_pages) 315 | 316 | # we could add more pages after the survey here 317 | # ... 318 | ``` 319 | 320 | **Have a look into the example implementations provided as `otreeutils_example1` (understanding questions, simple page extensions), `otreeutils_example2` (surveys) and `otreeutils_example3_market` (custom data models).** 321 | 322 | 323 | ### `otreeutils.scripts` module 324 | 325 | This module allows creating scripts that interface with oTree from the command line. Importing `otreeutils.scripts` makes sure that everything is correctly set up and the settings are loaded. An example might be a script which exports data from the current sessions for specific apps as JSON file: 326 | 327 | ```python 328 | import sys 329 | 330 | from otreeutils import scripts # this is the most import line and must be included at the beginning 331 | 332 | 333 | if len(sys.argv) != 2: 334 | print('call this script with a single argument: python %s ' % sys.argv[0]) 335 | exit(1) 336 | 337 | output_file = sys.argv[1] 338 | 339 | apps = ['intro', 340 | 'my_app', 341 | 'outro'] 342 | 343 | print('loading data...') 344 | 345 | # get the data as hierarchical data structure. this is esp. useful if you use 346 | # custom data models 347 | combined = scripts.get_hierarchical_data_for_apps(apps) 348 | 349 | print('writing data to file', output_file) 350 | 351 | scripts.save_data_as_json_file(combined, output_file, indent=2) 352 | 353 | print('done.') 354 | ``` 355 | 356 | ### Custom data models and admin extensions 357 | 358 | If you implement custom data models and want to use otreeutils' admin extensions you additionally need to follow these steps: 359 | 360 | #### 1. Install all dependencies 361 | 362 | Make sure that you install otreeutils with extra dependencies via `pip install otreeutils[admin]`. 363 | 364 | #### 2. Add configuration class to custom models 365 | 366 | For each of the custom models that you want to include in the live data view or extended data export, you have to define a subclass called `CustomModelConf` like this: 367 | 368 | ```python 369 | from otree.db.models import Model, ForeignKey # import base Model class and ForeignKey 370 | 371 | # ... 372 | 373 | class FruitOffer(Model): 374 | amount = models.IntegerField(label='Amount', min=0, initial=0) 375 | 376 | # ... more fields here ... 377 | 378 | seller = ForeignKey(Player) 379 | 380 | 381 | class CustomModelConf: 382 | """ 383 | Configuration for otreeutils admin extensions. 384 | """ 385 | data_view = { # define this attribute if you want to include this model in the live data view 386 | 'exclude_fields': ['seller'], 387 | 'link_with': 'seller' 388 | } 389 | export_data = { # define this attribute if you want to include this model in the data export 390 | 'exclude_fields': ['seller_id'], 391 | 'link_with': 'seller' 392 | } 393 | 394 | ``` 395 | 396 | #### 3. Add a custom urls module 397 | 398 | In your experiment app, add a file `urls.py` and simply include the custom URL patters from otreeutils as follows: 399 | 400 | ```python 401 | from otreeutils.admin_extensions.urls import urlpatterns 402 | 403 | # add more custom URL rules here if necessary 404 | # ... 405 | ``` 406 | 407 | #### 4. Import the `custom_export` function in `models.py` 408 | 409 | In your experiment app, add the following line to `models.py`: 410 | 411 | ```python 412 | from otreeutils.admin_extensions import custom_export 413 | ``` 414 | 415 | This makes sure that the data from the custom data models can be exported via oTree's admin interface. 416 | 417 | #### 5. Update `settings.py` to load the custom URLs and channel routes 418 | 419 | Add this line to your `settings.py`: 420 | 421 | ```python 422 | ROOT_URLCONF = '.urls' 423 | ``` 424 | 425 | Instead of `` write your app's package name (e.g. "market" if your app is named "market"). 426 | 427 | **And don't forget to edit your settings.py so that you add "otreeutils" to your INSTALLED_APPS list!** 428 | 429 | That's it! When you visit the admin pages, they won't really look different, however, the live data view will now support your custom models and in the data export view you can download the data *including* the custom models' data with the "custom" link. **So far, the "all-apps" download option will not include the custom models' data.** 430 | 431 | 432 | ## License 433 | 434 | Apache License 2.0. See LICENSE file. 435 | -------------------------------------------------------------------------------- /otreeutils/admin_extensions/views.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom admin views. 3 | 4 | Override existing oTree admin views for custom data live view and custom data export. 5 | 6 | Feb. 2021, Markus Konrad 7 | """ 8 | 9 | import json 10 | from collections import OrderedDict, defaultdict 11 | 12 | from django.http import JsonResponse 13 | from django.shortcuts import get_object_or_404 14 | 15 | from otree.views.admin import SessionData, SessionDataAjax 16 | from otree import export 17 | from otree.common import get_models_module 18 | from otree.db.models import Model 19 | from otree.models.participant import Participant 20 | from otree.models.session import Session 21 | import pandas as pd 22 | pd.set_option('display.max_columns', 100) 23 | pd.set_option('display.width', 180) 24 | 25 | 26 | #%% helper functions 27 | 28 | 29 | def _rows_per_key_from_queryset(qs, key): 30 | """Make a dict with `row[key] -> [rows with same key]` mapping (rows is a list).""" 31 | res = defaultdict(list) 32 | 33 | for row in qs.values(): 34 | res[row[key]].append(row) 35 | 36 | return res 37 | 38 | 39 | def _set_of_ids_from_rows_per_key(rows, idfield): 40 | return set(x[idfield] for r in rows.values() for x in r) 41 | 42 | 43 | def _odict_from_row(row, columns, is_obj=False): 44 | """Create an OrderedDict from a dict `row` using the columns in the order of `columns`.""" 45 | return OrderedDict((c, export.sanitize_for_csv(getattr(row, c) if is_obj else row[c])) for c in columns) 46 | 47 | 48 | def flatten_list(l): 49 | f = [] 50 | for items in l: 51 | f.extend(items) 52 | 53 | return f 54 | 55 | 56 | def sanitize_pdvalue_for_live_update(x): 57 | if pd.isna(x): 58 | return '' 59 | else: 60 | x_ = export.sanitize_for_live_update(x) 61 | 62 | # this is necessary because pandas transforms int columns with NA values to float columns: 63 | if x_.endswith('.0') and isinstance(x, (float, int)): 64 | x_ = x_[:x_.rindex('.')] 65 | return x_ 66 | 67 | 68 | def sanitize_pdvalue_for_csv(x): 69 | if pd.isna(x): 70 | return '' 71 | else: 72 | x_ = str(export.sanitize_for_csv(x)) 73 | 74 | # this is necessary because pandas transforms int columns with NA values to float columns: 75 | if x_.endswith('.0') and isinstance(x, (float, int)): 76 | x_ = x_[:x_.rindex('.')] 77 | return x_ 78 | 79 | 80 | #%% data export functions 81 | 82 | 83 | def get_hierarchical_data_for_apps(apps): 84 | """ 85 | Return a hierarchical data structure consisting of nested OrderedDicts for all data collected for apps listed 86 | in `apps`. The format of the returned data structure is: 87 | 88 | ``` 89 | { 90 | : { 91 | 'code': ..., 92 | 'label': ..., 93 | # more session data 94 | # ... 95 | '__apps': { # list of apps as requested in `apps` argument 96 | : [ # list of subsessions in app 1 played in session 1 97 | { 98 | 'round_number': 1, 99 | # more subsession data 100 | # ... 101 | '__group': [ # list of groups in subsession 1 of app 1 played in session 1 102 | { 103 | 'id_in_subsession': 1, 104 | # more group data 105 | # ... 106 | '__player': [ # list of players in group 1 in subsession 1 of app 1 played in session 1 107 | { 108 | "id_in_group": 1, 109 | # more player data 110 | # ... 111 | '__participant': { # reference to participant for this player 112 | "id_in_session": 1, 113 | "code": "5ilq0fad", 114 | # more participant data 115 | # ... 116 | }, 117 | '__custom_model': [ # some optional custom model data connected to this player (could also be connected to group or subsession) 118 | # custom model data 119 | ] 120 | }, # more players in this group 121 | ] 122 | }, # more groups in this session 123 | ] 124 | }, # more subsessions (rounds) in this app 125 | ] 126 | }, # more apps in this session 127 | }, 128 | : { # similar to above }, 129 | # ... 130 | } 131 | ``` 132 | """ 133 | 134 | combined = OrderedDict() 135 | 136 | for app in apps: 137 | sessions = get_hierarchical_data_for_app(app) 138 | 139 | for sess in sessions: 140 | sesscode = sess['code'] 141 | if sesscode not in combined.keys(): 142 | combined[sesscode] = OrderedDict([(k, v) for k, v in sess.items() if k != '__subsession']) 143 | combined[sesscode]['__apps'] = OrderedDict() 144 | 145 | combined[sesscode]['__apps'][app] = sess['__subsession'] 146 | 147 | return combined 148 | 149 | 150 | def get_hierarchical_data_for_app(app_name, return_columns=False): 151 | """ 152 | Generate hierarchical structured data for app `app_name`, optionally returning flattened field names. 153 | """ 154 | 155 | models_module = get_models_module(app_name) 156 | 157 | # get the standard models 158 | Player = models_module.Player 159 | Group = models_module.Group 160 | Subsession = models_module.Subsession 161 | 162 | # get the custom models configuration 163 | custom_models_conf = get_custom_models_conf(models_module, for_action='export_data') 164 | 165 | # build standard models' columns 166 | columns_for_models = {m.__name__.lower(): export.get_fields_for_csv(m) 167 | for m in [Player, Group, Subsession, Participant, Session]} 168 | 169 | # build custom models' columns 170 | columns_for_custom_models = get_custom_models_columns(custom_models_conf, for_action='export_data') 171 | 172 | custom_models_links = get_links_between_std_and_custom_models(custom_models_conf, for_action='export_data') 173 | std_models_select_related = defaultdict(list) 174 | for smodel_class, cmodels_links in custom_models_links.items(): 175 | smodel_lwr = smodel_class.__name__.lower() 176 | for cmodel_class, _ in cmodels_links: 177 | std_models_select_related[smodel_lwr].append(cmodel_class.__name__.lower()) 178 | 179 | # create lists of IDs that will be used for the export 180 | participant_ids = set(Player.objects.values_list('participant_id', flat=True)) 181 | session_ids = set(Subsession.objects.values_list('session_id', flat=True)) 182 | 183 | # create standard model querysets 184 | qs_participant = Participant.objects.filter(id__in=participant_ids) 185 | qs_player = Player.objects.filter(session_id__in=session_ids)\ 186 | .order_by('id')\ 187 | .select_related(*std_models_select_related.get('player', [])).values() 188 | qs_group = Group.objects.filter(session_id__in=session_ids)\ 189 | .select_related(*std_models_select_related.get('group', [])) 190 | qs_subsession = Subsession.objects.filter(session_id__in=session_ids)\ 191 | .select_related(*std_models_select_related.get('subsession', [])) 192 | 193 | # create prefetch dictionaries from querysets that map IDs to subsets of the data 194 | 195 | prefetch_filter_ids_for_custom_models = {} # stores IDs per standard oTree model to be used for 196 | # custom data prefetching 197 | 198 | # session ID -> subsession rows for this session 199 | prefetch_subsess = _rows_per_key_from_queryset(qs_subsession, 'session_id') 200 | prefetch_filter_ids_for_custom_models['subsession'] = _set_of_ids_from_rows_per_key(prefetch_subsess, 'id') 201 | 202 | # subsession ID -> group rows for this subsession 203 | prefetch_group = _rows_per_key_from_queryset(qs_group, 'subsession_id') 204 | prefetch_filter_ids_for_custom_models['group'] = _set_of_ids_from_rows_per_key(prefetch_group, 'id') 205 | 206 | # group ID -> player rows for this group 207 | prefetch_player = _rows_per_key_from_queryset(qs_player, 'group_id') 208 | prefetch_filter_ids_for_custom_models['player'] = _set_of_ids_from_rows_per_key(prefetch_player, 'id') 209 | 210 | # prefetch dict for custom data models 211 | prefetch_custom = defaultdict(dict) # standard oTree model name -> custom model name -> data rows 212 | for smodel, cmodel_links in custom_models_links.items(): # per oTree std. model 213 | smodel_name_lwr = smodel.__name__.lower() 214 | 215 | # IDs that occur for that model 216 | filter_ids = prefetch_filter_ids_for_custom_models[smodel_name_lwr] 217 | 218 | # iterate per custom model 219 | for model, link_field_name in cmodel_links: 220 | # prefetch custom model objects that are linked to these oTree std. model IDs 221 | filter_kwargs = {link_field_name + '__in': filter_ids} 222 | custom_qs = model.objects.filter(**filter_kwargs).values() 223 | 224 | # store to the dict 225 | m = model.__name__.lower() 226 | prefetch_custom[smodel_name_lwr][m] = _rows_per_key_from_queryset(custom_qs, link_field_name) 227 | 228 | # build the final nested data structure 229 | output_nested = [] 230 | ordered_columns_per_model = OrderedDict() 231 | # 1. each session 232 | for sess in Session.objects.filter(id__in=session_ids).values(): 233 | sess_cols = columns_for_models['session'] 234 | if 'session' not in ordered_columns_per_model: 235 | ordered_columns_per_model['session'] = sess_cols 236 | 237 | out_sess = _odict_from_row(sess, sess_cols) 238 | 239 | # 1.1. each subsession in the session 240 | out_sess['__subsession'] = [] 241 | for subsess in prefetch_subsess[sess['id']]: 242 | subsess_cols = columns_for_models['subsession'] 243 | if 'subsession' not in ordered_columns_per_model: 244 | ordered_columns_per_model['subsession'] = subsess_cols 245 | 246 | out_subsess = _odict_from_row(subsess, subsess_cols) 247 | 248 | # 1.1.1. each possible custom models connected to this subsession 249 | subsess_custom_models_rows = prefetch_custom.get('subsession', {}) 250 | for subsess_cmodel_name, subsess_cmodel_rows in subsess_custom_models_rows.items(): 251 | cmodel_cols = columns_for_custom_models[subsess_cmodel_name] 252 | if subsess_cmodel_name not in ordered_columns_per_model: 253 | ordered_columns_per_model[subsess_cmodel_name] = cmodel_cols 254 | 255 | out_subsess['__' + subsess_cmodel_name] = [_odict_from_row(cmodel_row, cmodel_cols) 256 | for cmodel_row in subsess_cmodel_rows[subsess['id']]] 257 | 258 | # 1.1.2. each group in this subsession 259 | out_subsess['__group'] = [] 260 | for grp in prefetch_group[subsess['id']]: 261 | grp_cols = columns_for_models['group'] 262 | if 'group' not in ordered_columns_per_model: 263 | ordered_columns_per_model['group'] = grp_cols 264 | 265 | out_grp = _odict_from_row(grp, grp_cols) 266 | 267 | # 1.1.2.1. each possible custom models connected to this group 268 | grp_custom_models_rows = prefetch_custom.get('group', {}) 269 | for grp_cmodel_name, grp_cmodel_rows in grp_custom_models_rows.items(): 270 | cmodel_cols = columns_for_custom_models[grp_cmodel_name] 271 | if grp_cmodel_name not in ordered_columns_per_model: 272 | ordered_columns_per_model[grp_cmodel_name] = cmodel_cols 273 | 274 | out_grp['__' + grp_cmodel_name] = [_odict_from_row(cmodel_row, cmodel_cols) 275 | for cmodel_row in grp_cmodel_rows[grp['id']]] 276 | 277 | # 1.1.2.2. each player in this group 278 | out_grp['__player'] = [] 279 | for player in prefetch_player[grp['id']]: 280 | # because player.payoff is a property 281 | player['payoff'] = player['_payoff'] 282 | player['role'] = player['_role'] 283 | 284 | player_cols = columns_for_models['player'] + ['participant_id'] 285 | if 'player' not in ordered_columns_per_model: 286 | ordered_columns_per_model['player'] = player_cols 287 | 288 | out_player = _odict_from_row(player, player_cols) 289 | 290 | # 1.1.2.2.1. participant object connected to this player 291 | participant_obj = qs_participant.get(id=out_player['participant_id']) 292 | out_player['__participant'] = _odict_from_row(participant_obj, 293 | columns_for_models['participant'], 294 | is_obj=True) 295 | out_player['__participant']['vars'] = participant_obj.vars 296 | 297 | # 1.1.2.2.2. each possible custom models connected to this player 298 | player_custom_models_rows = prefetch_custom.get('player', {}) 299 | for player_cmodel_name, player_cmodel_rows in player_custom_models_rows.items(): 300 | cmodel_cols = columns_for_custom_models[player_cmodel_name] 301 | if player_cmodel_name not in ordered_columns_per_model: 302 | ordered_columns_per_model[player_cmodel_name] = cmodel_cols 303 | 304 | out_player['__' + player_cmodel_name] = [_odict_from_row(cmodel_row, cmodel_cols) 305 | for cmodel_row in player_cmodel_rows[player['id']]] 306 | 307 | out_grp['__player'].append(out_player) 308 | 309 | out_subsess['__group'].append(out_grp) 310 | 311 | out_sess['__subsession'].append(out_subsess) 312 | 313 | output_nested.append(out_sess) 314 | 315 | # generate column names 316 | columns_flat = [] 317 | for model_name, model_cols in ordered_columns_per_model.items(): 318 | columns_flat.extend(['.'.join((model_name, c)) for c in model_cols]) 319 | 320 | if return_columns: 321 | return output_nested, columns_flat 322 | else: 323 | return output_nested 324 | 325 | 326 | def get_links_between_std_and_custom_models(custom_models_conf, for_action): 327 | """ 328 | Identify the links between custom models and standard models using custom models configuration `custom_models_conf`. 329 | Return as dict with lists: 330 | standard model class -> list of tuples (custom model class, link field name) 331 | """ 332 | 333 | std_to_custom = defaultdict(list) 334 | 335 | for model_name, conf in custom_models_conf.items(): 336 | model = conf['class'] 337 | 338 | # get the name and field instance of the link 339 | link_field_name = conf[for_action]['link_with'] 340 | link_field = getattr(model, link_field_name) 341 | 342 | # get the related standard oTree model 343 | rel_model = link_field.field.related_model 344 | 345 | # save to dicts 346 | std_to_custom[rel_model].append((conf['class'], link_field_name + '_id')) 347 | 348 | return std_to_custom 349 | 350 | 351 | def get_modelnames_from_links_between_std_and_custom_models_structure(std_to_custom): 352 | """ 353 | Get model names from output of `get_links_between_std_and_custom_models()` 354 | 355 | Return as dict with lists: standard model name -> list of lowercase custom model names 356 | """ 357 | 358 | modelnames = defaultdict(list) 359 | 360 | for std_model_name, (custom_model_class, _) in std_to_custom.items(): 361 | modelnames[std_model_name.__name__.lower()].append(custom_model_class.__name__.lower()) 362 | 363 | return modelnames 364 | 365 | 366 | def get_dataframe_from_linked_models(std_models_querysets, links_to_custom_models, 367 | std_models_colnames, custom_models_colnames): 368 | """ 369 | Create a dataframe that joins data from standard models in `std_models_querysets` with data from custom models 370 | via `links_to_custom_models`. Use columns defined in `std_models_colnames` for standard models and 371 | `custom_models_colnames` for custom models. 372 | 373 | `std_models_querysets` is a list of tuples, with each row containing: 374 | - the standard oTree model (Subession, Group or Player) 375 | - the queryset to fetch the data 376 | - a tuple (left field name, right field name) defining the columns to use for joining the data 377 | 378 | `links_to_custom_models` comes from `get_modelnames_from_links_between_std_and_custom_models_structure()` and is 379 | a dict with lists: standard model name -> list of lowercase custom model names. 380 | 381 | The first dataframe fetched via `std_models_querysets` defines the base data. Every following dataframe will be 382 | joined with the base dataframe and result in a new base dataframe to be joined in the next iteration. A left join 383 | will be performed for each iteration using the model links defined in each `std_models_querysets` row. 384 | 385 | Returns a data frame of joined data. Each column is prefixed by the lowercase model name, e.g. "player.payoff". 386 | """ 387 | df = None # base dataframe 388 | 389 | # iterate through each standard model queryset 390 | for smodel, smodel_qs, (smodel_link_left, smodel_link_right) in std_models_querysets: 391 | smodel_name = smodel.__name__ 392 | smodel_name_lwr = smodel_name.lower() 393 | smodel_colnames = std_models_colnames[smodel_name_lwr] 394 | 395 | if 'id' not in smodel_colnames: # always add the ID field (necessary for joining) 396 | smodel_colnames += ['id'] 397 | 398 | # optionally add field for the right side of the join 399 | remove_right_link_col = False # remove it after joining 400 | if smodel_link_right: 401 | smodel_link_right_reduced = smodel_link_right[smodel_link_right.rindex('.')+1:] 402 | if smodel_link_right_reduced not in smodel_colnames: 403 | remove_right_link_col = True 404 | smodel_colnames += [smodel_link_right_reduced] 405 | 406 | # special handling for Player's attributes group, payoff and role 407 | if smodel_name == 'Player': 408 | if 'payoff' in smodel_colnames: 409 | smodel_colnames[smodel_colnames.index('payoff')] = '_payoff' 410 | smodel_colnames = [c for c in smodel_colnames if c not in {'role', 'group'}] 411 | 412 | if not smodel_qs.exists(): # create empty data frame with given column names 413 | df_smodel = pd.DataFrame(OrderedDict((c, []) for c in smodel_colnames)) 414 | else: # create and fill data frame fetching values from the queryset 415 | df_smodel = pd.DataFrame(list(smodel_qs.values()))[smodel_colnames] 416 | 417 | # special handling for Player's attributes payoff and role 418 | if smodel_name == 'Player': 419 | df_smodel.rename(columns={'_payoff': 'payoff'}, inplace=True) 420 | df_smodel['role'] = [p.role() if p.role else '' for p in smodel_qs] 421 | 422 | # prepend model name to each column 423 | renamings = dict((c, smodel_name_lwr + '.' + c) for c in df_smodel.columns) 424 | df_smodel.rename(columns=renamings, inplace=True) 425 | 426 | if df is None: # first dataframe is used as base dataframe 427 | assert smodel_link_left is None and smodel_link_right is None 428 | df = df_smodel 429 | else: # perform the left join, use result as new base dataframe for next iteration 430 | df = pd.merge(df, df_smodel, how='left', left_on=smodel_link_left, right_on=smodel_link_right) 431 | 432 | if smodel in links_to_custom_models: # we have custom model(s) linked to this standard model 433 | for cmodel, cmodel_link_field_name in links_to_custom_models[smodel]: 434 | cmodel_name = cmodel.__name__ 435 | cmodel_name_lwr = cmodel_name.lower() 436 | 437 | # fetch only the needed IDs 438 | smodel_ids = df_smodel[smodel_name_lwr + '.id'].unique() 439 | cmodel_qs = cmodel.objects.filter(**{cmodel_link_field_name + '__in': smodel_ids}) 440 | cmodel_colnames = custom_models_colnames[cmodel_name_lwr] 441 | 442 | # optionally add field for the right side of the join 443 | remove_cmodel_link_field_name = False # remove it after joining 444 | if cmodel_link_field_name not in cmodel_colnames: 445 | cmodel_colnames += [cmodel_link_field_name] 446 | remove_cmodel_link_field_name = True 447 | 448 | if not cmodel_qs.exists(): # create empty data frame with given column names 449 | df_cmodel = pd.DataFrame(OrderedDict((c, []) for c in cmodel_colnames)) 450 | else: # create and fill data frame fetching values from the queryset 451 | df_cmodel = pd.DataFrame(list(cmodel_qs.values()))[cmodel_colnames] 452 | 453 | # prepend model name to each column 454 | renamings = dict((c, cmodel_name_lwr + '.' + c) for c in df_cmodel.columns) 455 | df_cmodel.rename(columns=renamings, inplace=True) 456 | 457 | cmodel_link_field_name = cmodel_name_lwr + '.' + cmodel_link_field_name 458 | 459 | # perform the left join, use result as new base dataframe for next iteration 460 | df = pd.merge(df, df_cmodel, how='left', 461 | left_on=smodel_name_lwr + '.id', 462 | right_on=cmodel_link_field_name) 463 | 464 | if remove_cmodel_link_field_name: 465 | del df[cmodel_link_field_name] 466 | 467 | if remove_right_link_col: 468 | del df[smodel_link_right] 469 | 470 | return df 471 | 472 | 473 | def get_custom_models_conf(models_module, for_action): 474 | """ 475 | Obtain the custom models defined in the models.py module `models_module` of an app for a certain action (`data_view` 476 | or `export_data`). 477 | 478 | These models must have a subclass `CustomModelConf` with the respective configuration attributes `data_view` 479 | or `export_data`. 480 | 481 | Returns a dictionary with `model name` -> `model config dict`. 482 | """ 483 | assert for_action in ('data_view', 'export_data') 484 | 485 | custom_models_conf = {} 486 | for attr in dir(models_module): 487 | val = getattr(models_module, attr) 488 | try: 489 | if issubclass(val, Model): # must be a django model 490 | metaclass = getattr(val, 'CustomModelConf', None) 491 | if metaclass and hasattr(metaclass, for_action): 492 | custom_models_conf[attr] = { 493 | 'class': val, 494 | for_action: getattr(metaclass, for_action) 495 | } 496 | except TypeError: 497 | pass 498 | 499 | return custom_models_conf 500 | 501 | 502 | def get_field_names_for_custom_model(model, conf, use_attname=False): 503 | """ 504 | Obtain fields for a custom model `model`, depending on its configuration `conf`. 505 | If `use_attname` is True, use the `attname` property of the field, else the `name` property ("attname" has a "_id" 506 | suffix for ForeignKeys). 507 | """ 508 | if 'fields' in conf: 509 | fields = conf['fields'] 510 | else: 511 | fields = [f.attname if use_attname else f.name for f in model._meta.fields] 512 | 513 | exclude = set(conf.get('exclude_fields', [])) 514 | 515 | return [f for f in fields if f not in exclude] 516 | 517 | 518 | def get_custom_models_columns(custom_model_conf, for_action): 519 | """ 520 | Obtain columns (fields) for each custom model in `custom_model_conf`. 521 | """ 522 | 523 | columns_for_models = {name.lower(): get_field_names_for_custom_model(conf['class'], conf.get(for_action, {}), 524 | use_attname=True) 525 | for name, conf in custom_model_conf.items()} 526 | 527 | return columns_for_models 528 | 529 | 530 | def flatten_model_colnames(model_colnames): 531 | """ 532 | From a dict that maps model name to a list of field names, generate a list with string elements `.`. 533 | """ 534 | flat_colnames = [] 535 | for modelname, colnames in model_colnames.items(): 536 | flat_colnames.extend([modelname + '.' + c for c in colnames]) 537 | 538 | return flat_colnames 539 | 540 | 541 | def combine_column_names(std_models_colnames, custom_models_colnames, drop_columns=('group.id_in_subsession',)): 542 | """ 543 | Combine column (or: field) names from standard models `std_models_colnames` and 544 | custom models `custom_models_colnames` to a list with all column names. 545 | """ 546 | all_colnames = flatten_model_colnames({'player': std_models_colnames['player']}) \ 547 | + flatten_model_colnames(custom_models_colnames) \ 548 | + flatten_model_colnames({m: std_models_colnames[m] for m in std_models_colnames.keys() 549 | if m != 'player'}) 550 | return [c for c in all_colnames if c not in drop_columns] 551 | 552 | 553 | def get_custom_models_conf_per_app(session): 554 | """ 555 | Get the custom models configuration dict for all apps in running in `session`. 556 | """ 557 | 558 | custom_models_conf_per_app = {} 559 | for app_name in session.config['app_sequence']: 560 | models_module = get_models_module(app_name) 561 | conf = get_custom_models_conf(models_module, for_action='data_view') 562 | if conf: 563 | custom_models_conf_per_app[app_name] = conf 564 | 565 | return custom_models_conf_per_app 566 | 567 | 568 | def get_rows_for_data_tab(session): 569 | """ 570 | Overridden function from `otree.export` module to provide data rows for the session data monitor. 571 | """ 572 | for app_name in session.config['app_sequence']: 573 | yield from get_rows_for_data_tab_app(session, app_name) 574 | 575 | 576 | def get_rows_for_data_tab_app(session, app_name): 577 | """ 578 | Overridden function from `otree.export` module to provide data rows for the session data monitor for a specific app. 579 | """ 580 | 581 | models_module = get_models_module(app_name) 582 | Player = models_module.Player 583 | Group = models_module.Group 584 | Subsession = models_module.Subsession 585 | 586 | pfields, gfields, sfields = export.get_fields_for_data_tab(app_name) 587 | 588 | # find out column names for standard models 589 | std_models_colnames = dict(zip(('player', 'group', 'subsession'), (pfields, gfields, sfields))) 590 | 591 | # get custom model configuration, if there is any 592 | custom_models_conf = get_custom_models_conf(models_module, for_action='data_view') 593 | 594 | # find out column names for custom models 595 | custom_models_colnames = get_custom_models_columns(custom_models_conf, for_action='data_view') 596 | 597 | # identify links between standard and custom models 598 | links_to_custom_models = get_links_between_std_and_custom_models(custom_models_conf, for_action='data_view') 599 | 600 | # all displayed columns in their order 601 | all_colnames = combine_column_names(std_models_colnames, custom_models_colnames) 602 | 603 | # iterate through the subsessions (i.e. rounds) 604 | for subsess_id in Subsession.objects.filter(session=session).values('id'): 605 | subsess_id = subsess_id['id'] 606 | # pre-filter querysets to get only data of this subsession 607 | filter_in_subsess = dict(subsession_id__in=[subsess_id]) 608 | 609 | # define querysets for standard models and their links for merging as left index, right index 610 | # the order is important! 611 | std_models_querysets = ( 612 | (Subsession, Subsession.objects.filter(id=subsess_id), (None, None)), 613 | (Group, Group.objects.filter(**filter_in_subsess), ('subsession.id', 'group.subsession_id')), 614 | (Player, Player.objects.filter(**filter_in_subsess), ('group.id', 'player.group_id')), 615 | ) 616 | 617 | # create a dataframe for this subsession's complete data incl. custom models data 618 | df = get_dataframe_from_linked_models(std_models_querysets, links_to_custom_models, 619 | std_models_colnames, custom_models_colnames) 620 | 621 | # sanitize each value 622 | df = df.applymap(sanitize_pdvalue_for_live_update)\ 623 | .rename(columns={'group.id_in_subsession': 'player.group'})[all_colnames] 624 | 625 | yield df.to_dict(orient='split')['data'] 626 | 627 | 628 | def get_rows_for_custom_export(app_name): 629 | """ 630 | Provide data rows for custom export function of an app. Used in default custom export function 631 | `otreeutils.admin_extensions.custom_export`. 632 | """ 633 | 634 | models_module = get_models_module(app_name) 635 | Player = models_module.Player 636 | Group = models_module.Group 637 | Subsession = models_module.Subsession 638 | 639 | # find out column names for standard models 640 | std_models_colnames = {m.__name__.lower(): export.get_fields_for_csv(m) 641 | for m in (Session, Subsession, Group, Player, Participant)} 642 | std_models_colnames['player'].append('participant_id') 643 | 644 | # get custom model configuration, if there is any 645 | custom_models_conf = get_custom_models_conf(models_module, for_action='data_view') 646 | 647 | # find out column names for custom models 648 | custom_models_colnames = get_custom_models_columns(custom_models_conf, for_action='data_view') 649 | 650 | # identify links between standard and custom models 651 | links_to_custom_models = get_links_between_std_and_custom_models(custom_models_conf, for_action='data_view') 652 | 653 | # define querysets for standard models and their links for merging as left index, right index 654 | # the order is important! 655 | 656 | # create lists of IDs that will be used for the export 657 | participant_ids = set(Player.objects.values_list('participant_id', flat=True)) 658 | session_ids = set(Subsession.objects.values_list('session_id', flat=True)) 659 | 660 | filter_in_sess = {'session_id__in': session_ids} 661 | 662 | std_models_querysets = ( 663 | (Session, Session.objects.filter(id__in=session_ids), (None, None)), 664 | (Subsession, Subsession.objects.filter(**filter_in_sess), ('session.id', 'subsession.session_id')), 665 | (Group, Group.objects.filter(**filter_in_sess), ('subsession.id', 'group.subsession_id')), 666 | (Player, Player.objects.filter(**filter_in_sess), ('group.id', 'player.group_id')), 667 | (Participant, Participant.objects.filter(id__in=participant_ids), ('player.participant_id', 'participant.id')), 668 | ) 669 | 670 | # create a dataframe for this subsession's complete data incl. custom models data 671 | df = get_dataframe_from_linked_models(std_models_querysets, links_to_custom_models, 672 | std_models_colnames, custom_models_colnames) 673 | 674 | # sanitize each value 675 | split_data = df.applymap(sanitize_pdvalue_for_csv).to_dict(orient='split') 676 | 677 | yield split_data['columns'] 678 | for row in split_data['data']: 679 | yield row 680 | 681 | 682 | class SessionDataExtension(SessionData): 683 | """ 684 | Extension to oTree's live session data viewer. 685 | """ 686 | def vars_for_template(self): 687 | session = self.session 688 | 689 | custom_models_conf_per_app = get_custom_models_conf_per_app(session) 690 | if not custom_models_conf_per_app: # no custom models -> use default oTree method 691 | return super(SessionDataExtension, self).vars_for_template() 692 | 693 | tables = [] 694 | field_headers = {} 695 | app_names_by_subsession = [] 696 | round_numbers_by_subsession = [] 697 | for app_name in session.config['app_sequence']: 698 | models_module = get_models_module(app_name) 699 | num_rounds = models_module.Subsession.objects.filter( 700 | session=session 701 | ).count() 702 | 703 | custom_models_conf = get_custom_models_conf(models_module, for_action='data_view') 704 | 705 | # find out column names for custom models 706 | custom_models_colnames = get_custom_models_columns(custom_models_conf, for_action='data_view') 707 | 708 | pfields, gfields, sfields = export.get_fields_for_data_tab(app_name) 709 | gfields = [c for c in gfields if c != 'id_in_subsession'] 710 | std_models_colnames = dict(zip(('player', 'group', 'subsession'), (pfields, gfields, sfields))) 711 | 712 | # all displayed columns in their order 713 | field_headers[app_name] = combine_column_names(std_models_colnames, custom_models_colnames) 714 | 715 | for round_number in range(1, num_rounds + 1): 716 | table = dict(pfields=pfields, cfields=custom_models_colnames, gfields=gfields, sfields=sfields) 717 | tables.append(table) 718 | 719 | app_names_by_subsession.append(app_name) 720 | round_numbers_by_subsession.append(round_number) 721 | 722 | return dict( 723 | tables=tables, 724 | field_headers_json=json.dumps(field_headers), 725 | app_names_by_subsession=app_names_by_subsession, 726 | round_numbers_by_subsession=round_numbers_by_subsession, 727 | ) 728 | 729 | def get_template_names(self): 730 | if get_custom_models_conf_per_app(self.session): 731 | return ['otreeutils/admin/SessionDataExtension.html'] 732 | else: # no custom models -> use default oTree template 733 | return ['otree/admin/SessionData.html'] 734 | 735 | 736 | class SessionDataAjaxExtension(SessionDataAjax): 737 | """ 738 | Extension to oTree's live session data viewer: Asynchronous JSON data provider. 739 | """ 740 | 741 | def get(self, request, code): 742 | session = get_object_or_404(Session, code=code) 743 | 744 | if get_custom_models_conf_per_app(session): 745 | rows = list(get_rows_for_data_tab(session)) 746 | return JsonResponse(rows, safe=False) 747 | else: # no custom models -> use default oTree method 748 | return super(SessionDataAjaxExtension, self).get(request, code) 749 | --------------------------------------------------------------------------------