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:
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.
| Option | Meaning |
|---|---|
default | The state every new record starts in. |
moves | The allowed changes from one state to another. |
locked | States in which ordinary edits are refused. |
final | States with no way out. |
voidable | States 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 paidabove is for admins only.when: a formula that must be true on the saved record.Issueneeds 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_issuesets the remarks.
Run a move from Python with context.move:
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:
issued_on: This record is issued, so it is lockedThe 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.
inputsdeclares 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_whenis a formula on the saved record.Add noteworks only on an issued invoice.roleslists who may use it. The default is('admin', 'editor').- The method changes the record through
self. Evlet then works out formulas, runsvalidate()and saves, in one transaction.
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.