Docs

Actions and moves

A business document moves through stages: draft, issued, paid. Evlet models this with a State field and its moves. For other buttons on a record, such as "Add note", you write an action.

#The invoice

Here is the full invoice from the earlier pages, in apps/shop/models/invoice.py:

Python
from evlet import (Child, Currency, Date, Decimal, Link, Model, Move, Rule, State, Table, Text,
                   ValidationError, action)
from evlet.calc import F, round


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

    class Line(Child):
        product = Link('product', required=True)
        description = Text(fetch='product.product_name')
        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)
    issued_on = Date()
    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)
    remarks = Text(default='', editable_when_locked=True)
    status = State(
        ('draft', 'issued', 'paid', 'void'),
        default='draft',
        moves=(
            Move('draft', 'issued', 'Issue', when=F('total') > 0, run='on_issue'),
            Move('issued', 'paid', 'Mark paid', roles=('admin',)),
        ),
        locked=('issued', 'paid', 'void'),
        final=('paid', 'void'),
        voidable=('issued',),
    )

    class Meta:
        list_fields = ('customer', 'issued_on', 'total', 'status')

    def validate(self):
        if self.issued_on is None and self.status != 'draft':
            raise ValidationError({'issued_on': 'Set the issue date'})

    def on_issue(self):
        self.remarks = 'Issued'

    @action('Add note', inputs={'note': Text(required=True, max_length=200)},
            available_when=F('status') == 'issued')
    def add_note(self, note):
        self.remarks = note

#State

State is a choice field whose value changes only through its moves. A model has at most one.

OptionMeaning
defaultThe state every new record starts in.
movesThe allowed changes from one state to another.
lockedStates in which ordinary edits are refused.
finalStates with no way out.
voidableStates from which the record can be voided, with a reason. A State with voidable states needs a void state that is locked and final.

Nobody can set the State by typing a value or sending it in an update. A new record always starts in default. Saving the same value back is fine.

#Moves

Move(from_state, to_state, label) declares one allowed change. It takes:

  • roles: who may use it. The default is ('admin', 'editor'). Mark paid above is for admins only.
  • when: a formula that must be true on the saved record. Issue needs a total above zero.
  • run: the name of a method to run after the state changes and before the record is checked and saved. on_issue sets the remarks.

Run a move from Python with context.move:

Python
with app.business.context(user) as context:
    context.update(Invoice, invoice['id'], issued_on='2026-11-12')
    issued = context.move(Invoice, invoice['id'], 'issued')

The move, its run method, the formulas, validate() and the save happen in one transaction. If any part fails, nothing changes. Without an issue date, the move above fails with issued_on: Set the issue date.

Over HTTP, a move is POST /api/invoice/{id}/moves/issued. It needs the record's current ETag in an If-Match header, so two people cannot move the same record at once.

#Locked states

Once the invoice is issued, it is locked. An ordinary edit fails:

Output
issued_on: This record is issued, so it is locked

The lock covers every field and every row of its tables. Fields declared with editable_when_locked=True, such as remarks, stay editable. A locked record also keeps its worked-out values: the totals stay as they were when it was locked.

#Actions

An action is a method marked with @action. The record page shows it as a button to the roles that may use it.

  • inputs declares the values the action asks for. Each input is a field, so it is checked like one. The method takes the inputs as keyword arguments.
  • available_when is a formula on the saved record. Add note works only on an issued invoice.
  • roles lists who may use it. The default is ('admin', 'editor').
  • The method changes the record through self. Evlet then works out formulas, runs validate() and saves, in one transaction.
Python
noted = context.action(Invoice, invoice['id'], 'add_note', note='Sent by email')

Over HTTP, an action is POST /api/invoice/{id}/actions/add_note with the inputs as JSON and an If-Match header.

#Writing other records

Inside a run method or an action, self.context.session is the transaction itself. Use it when a move must also write other records, such as stock movements, so that everything commits or fails together. These writes are trusted code and skip permission checks. Change the record being moved through self, not through the session.