Tips and tricks

Preventing code duplication

As much as possible, it’s good to avoid copy-pasting the same code in multiple places. Although it sometimes takes a bit of thinking to figure out how to avoid copy-pasting code, you will see that having your code in only one place usually saves you a lot of effort later when you need to change the design of your code or fix bugs.

Below are some techniques to achieve code reuse.

Don’t make multiple copies of your app

If possible, you should avoid copying an app’s folder to make a slightly different version, because then you have duplicated code that is harder to maintain.

If you need multiple rounds, set NUM_ROUNDS. If you need slightly different versions (e.g. different treatments), then you should use the techniques described in Treatments, such as making 2 session configs that have a different 'treatment' parameter, and then checking for session.config['treatment'] in your app’s code.

How to make many fields

Let’s say your app has many fields that are almost the same, such as:

class Player(BasePlayer):

    f1 = models.IntegerField(
        choices=[-1, 0, 1], widget=widgets.RadioSelect,
        blank=True, initial=0
    )
    f2 = models.IntegerField(
        choices=[-1, 0, 1], widget=widgets.RadioSelect,
        blank=True, initial=0
    )
    f3 = models.IntegerField(
        choices=[-1, 0, 1], widget=widgets.RadioSelect,
        blank=True, initial=0
    )
    f4 = models.IntegerField(
        choices=[-1, 0, 1], widget=widgets.RadioSelect,
        blank=True, initial=0
    )
    f5 = models.IntegerField(
        choices=[-1, 0, 1], widget=widgets.RadioSelect,
        blank=True, initial=0
    )
    f6 = models.IntegerField(
        choices=[-1, 0, 1], widget=widgets.RadioSelect,
        blank=True, initial=0
    )
    f7 = models.IntegerField(
        choices=[-1, 0, 1], widget=widgets.RadioSelect,
        blank=True, initial=0
    )
    f8 = models.IntegerField(
        choices=[-1, 0, 1], widget=widgets.RadioSelect,
        blank=True, initial=0
    )
    f9 = models.IntegerField(
        choices=[-1, 0, 1], widget=widgets.RadioSelect,
        blank=True, initial=0
    )
    f10 = models.IntegerField(
        choices=[-1, 0, 1], widget=widgets.RadioSelect,
        blank=True, initial=0
    )

    # etc...

This is quite complex; you should look for a way to simplify.

Are the fields all displayed on separate pages? If so, consider converting this to a 10-round game with just one field.

If that’s not possible, then you can reduce the amount of repeated code by defining a function that returns a field:

def make_field(label):
    return models.IntegerField(
        choices=[1,2,3,4,5],
        label=label,
        widget=widgets.RadioSelect,
    )

class Player(BasePlayer):

    q1 = make_field('I am quick to understand things.')
    q2 = make_field('I use difficult words.')
    q3 = make_field('I am full of ideas.')
    q4 = make_field('I have excellent ideas.')

Prevent duplicate pages by using multiple rounds

If you have many many pages that are almost the same, consider just having 1 page and looping it for multiple rounds. One sign that your code can be simplified is if it looks something like this:

# [pages 1 through 7....]

class Decision8(Page):
    form_model = 'player'
    form_fields = ['decision8']

class Decision9(Page):
    form_model = 'player'
    form_fields = ['decision9']

# etc...

Avoid duplicated validation methods

If you have many repetitive FIELD_error_message methods, you can replace them with a single error_message function. For example:

def quiz1_error_message(player, value):
    if value != 42:
        return 'Wrong answer'

def quiz2_error_message(player, value):
    if value != 'Ottawa':
        return 'Wrong answer'

def quiz3_error_message(player, value):
    if value != 3.14:
        return 'Wrong answer'

def quiz4_error_message(player, value):
    if value != 'George Washington':
        return 'Wrong answer'

You can instead define this function on your page:

@staticmethod
def error_message(player, values):
    solutions = dict(
        quiz1=42,
        quiz2='Ottawa',
        quiz3='3.14',
        quiz4='George Washington'
    )

    error_messages = dict()

    for field_name in solutions:
        if values[field_name] != solutions[field_name]:
            error_messages[field_name] = 'Wrong answer'

    return error_messages

(Usually error_message is used to return a single error message as a string, but you can also return a dict.)

Avoid duplicated page functions

Any page function can be moved out of the page class, and into a top-level function. This is a handy way to share the same function across multiple pages. For example, let’s say many pages need to have these 2 functions:

class Page1(Page):
    @staticmethod
    def is_displayed(player: Player):
        participant = player.participant

        return participant.expiry - time.time() > 0

    @staticmethod
    def get_timeout_seconds(player):
        participant = player.participant
        import time
        return participant.expiry - time.time()

You can move those functions before all the pages (remove the @staticmethod), and then reference them wherever they need to be used:

def is_displayed1(player: Player):
    participant = player.participant

    return participant.expiry - time.time() > 0


def get_timeout_seconds1(player: Player):
    participant = player.participant
    import time

    return participant.expiry - time.time()


class Page1(Page):
    is_displayed = is_displayed1
    get_timeout_seconds = get_timeout_seconds1


class Page2(Page):
    is_displayed = is_displayed1
    get_timeout_seconds = get_timeout_seconds1

(In the sample games, after_all_players_arrive and live_method are frequently defined in this manner.)

Improving code performance

You should avoid redundant use of get_players(), get_player_by_id(), in_*_rounds(), get_others_in_group(), or any other methods that return a player or list of players. These methods all require a database query, which can be slow.

For example, this code has a redundant query because it asks the database 5 times for the exact same player:

@staticmethod
def vars_for_template(player):
    return dict(
        a=player.in_round(1).a,
        b=player.in_round(1).b,
        c=player.in_round(1).c,
        d=player.in_round(1).d,
        e=player.in_round(1).e
    )

It should be simplified to this:

@staticmethod
def vars_for_template(player):
    round_1_player = player.in_round(1)
    return dict(
        a=round_1_player.a,
        b=round_1_player.b,
        c=round_1_player.c,
        d=round_1_player.d,
        e=round_1_player.e
    )

As an added benefit, this usually makes the code more readable.

Use BooleanField instead of StringField, where possible

Many StringFields should be broken down into BooleanFields, especially if they only have 2 distinct values.

Suppose you have a field called treatment:

treatment = models.StringField()

And let’s say treatment it can only have 4 different values:

  • high_income_high_tax
  • high_income_low_tax
  • low_income_high_tax
  • low_income_low_tax

In your pages, you might use it like this:

class HighIncome(Page):
    @staticmethod
    def is_displayed(player):
        return player.treatment == 'high_income_high_tax' or player.treatment == 'high_income_low_tax'

class HighTax(Page):
    @staticmethod
    def is_displayed(player):
        return player.treatment == 'high_income_high_tax' or player.treatment == 'low_income_high_tax'

It would be much better to break this to 2 separate BooleanFields:

high_income = models.BooleanField()
high_tax = models.BooleanField()

Then your pages could be simplified to:

class HighIncome(Page):
    @staticmethod
    def is_displayed(player):
        return player.high_income

class HighTax(Page):
    @staticmethod
    def is_displayed(player):
        return player.high_tax

field_maybe_none

If you access a Player/Group/Subsession field whose value is None, oTree will raise a TypeError. This is designed to catch situations where a user forgot to assign a value to that field, or forgot to include it in form_fields.

However, sometimes you need to intentionally access a field whose value may be None. To do this, use field_maybe_none, which will suppress the error:

# instead of player.abc, do:
abc = player.field_maybe_none('abc')
# also works on group and subsession

Note

field_maybe_none is new in oTree 5.4 (August 2021).

An alternative solution is to assign an initial value to the field so that its value is never None:

abc = models.BooleanField(initial=False)
xyz = models.StringField(initial='')