Docs

Calculations and derive()

Many values on a business record are worked out from other values: a line amount, a total, a tax. Evlet has two tools for this. Formulas cover arithmetic and totals. derive() covers the rest, in plain Python.

#Formulas

A formula is a compute= option on a field. You write it with Python operators and a few helpers from evlet.calc:

Python
from evlet import Child, Currency, Decimal, Link, Model, Table
from evlet.calc import F, round


class Invoice(Model):
    class Line(Child):
        product = Link('product', required=True)
        qty = Decimal(scale=2, default=1, minimum='0')
        rate = Currency(currency='AED', initial=F('product.price'))
        amount = Currency(currency='AED', compute=round(qty * rate, 2))

    customer = Link('customer', required=True)
    lines = Table(Line)
    subtotal = Currency(currency='AED', compute=lines.sum('amount'))
    vat = Currency(currency='AED', compute=round(subtotal * 5 / 100, 2))
    total = Currency(currency='AED', compute=subtotal + vat)

With one line of 4 pens at 2.50, Evlet works out:

Output
subtotal 10.00   vat 0.50   total 10.50

How formulas behave:

  • Exact. All arithmetic uses exact decimals. round(x, n) rounds half up. A result is also rounded to its field's scale.
  • The same everywhere. The browser runs the formulas as a preview while people type. The server runs the same plan again before saving, and its result is the one stored.
  • Protected. People cannot type into a computed field. Evlet refuses a value sent for one.
  • Checked early. A formula that adds a date to a number, or reads a field that does not exist, fails when the app loads, not when someone saves.

What you can use in a formula:

You writeIt means
qty * rate, + - * /Arithmetic on number fields.
lines.sum('amount'), count(), min(), max()Totals over a child table. The sum of no rows is 0.
lines.where(taxable=True).sum('net')Totals over the rows that match.
F('customer.credit_days')A field on a linked record, up to three Links away.
when(cond, a, b)a when cond is true, else b.
coalesce(x, fallback)fallback when x is empty.
today(), days(n)Today's date in the site's time zone, and a number of days to add to a date.

Formulas are deliberately small. There are no loops, no function calls of your own and no imports. When you need more, use derive().

#derive(): values a record builds for itself

Some values need real Python: one row per distinct project, a list built from a linked record, a count. A model builds them in a derive(self) method and lists what it sets in Meta.derives.

This timesheet adds up hours per project. Create apps/shop/models/timesheet.py:

Python
from evlet import Child, Decimal, Integer, Model, Rule, Table, Text


class Timesheet(Model):
    permissions = {
        'admin': Rule(('read', 'create', 'update', 'delete')),
        'editor': Rule(('read', 'create', 'update')),
    }

    class Meta:
        derives = ('projects', 'project_count')

    class Entry(Child):
        project = Text(required=True, max_length=80)
        hours = Decimal(scale=2, default=0)

    class ProjectTotal(Child):
        project = Text(max_length=80)
        hours = Decimal(scale=2)

    employee = Text(required=True, max_length=120)
    entries = Table(Entry)
    projects = Table(ProjectTotal)
    project_count = Integer()

    def derive(self):
        totals = {}
        for entry in self.entries:
            totals[entry.project] = totals.get(entry.project, 0) + (entry.hours or 0)
        self.projects = [{'project': name, 'hours': hours} for name, hours in sorted(totals.items())]
        self.project_count = len(totals)

Save a timesheet with three entries, two of them for the same project:

Python
with app.business.context(user) as context:
    sheet = context.create(Timesheet, employee='Mariam', children={'entries': [
        {'project': 'Website', 'hours': '3'}, {'project': 'Audit', 'hours': '2.5'},
        {'project': 'Website', 'hours': '1'}]})

The saved record has project_count 2, and its projects rows are Audit 2.50 and Website 4.00.

How derive() behaves:

  • When it runs. On every save of the record, and on every preview in the browser. It runs after the values are cleaned and before the formulas, so formulas may read what it sets.
  • What it may set. Only the fields and tables named in Meta.derives. Setting anything else raises an error.
  • Nobody else may set them. A value sent for a derived field is refused with DERIVED_FIELD_WRITE: this value is set by the record. The form shows derived tables as read only.
  • Its values are checked. What derive() sets is cleaned and validated like typed input. Round it yourself: a value with more decimal places than its field allows is an error, not rounded for you. Use quantize() on a Decimal.
  • Read only lookups. Inside derive(), self.context.session can read other records (get, list, count), but it cannot write.

Adding a derived field or table to a model that has data needs a migration, like any other field.