Welcome | Get started | Dive into Lino | Contribute | Topics | Reference | More

Introduction to Workflows

This tutorial explains how to define and use workflows.

This tutorial extends the application created in watch -- Watching database changes, so you might want follow that tutorial before reading on here.

About this document

Examples on this page use the lino_book.projects.workflows sample application.

>>> from lino import startup
>>> startup('lino_book.projects.workflows.settings')
>>> from lino.api.doctest import *

You can play with this application by installing the latest development version of Lino, then cd to the workflows demo project where you can run:

$ go workflows
$ python manage.py prep
$ python manage.py runserver

What is a workflow?

A workflow in Lino is defined by (1) a "state" field on a model and (2) a list of "transitions", i.e. actions which change a given object from one state to another state.

For example consider this workflow:

digraph foo {

 New -> Started;
 New -> Done;
 Started -> Done;
 New -> Sleeping;
 Started -> Sleeping;
 New -> Cancelled;
 Started -> Cancelled;
 Sleeping -> Cancelled;
 Sleeping -> Started[label="Wake up"];
}

It consists of five states (New, Started, Done, Sleeping, Cancelled) and a series of transitions.

Defining the states

We use a choicelist to define the states. That choicelist must be a special kind of choicelist: a Workflow. The choices of a workflow are instances of State and behave mostly like normal choices, but have an additional method add_transition and an additional (optional) attribute button_text.

# -*- coding: UTF-8 -*-
# Copyright 2013-2021 Rumma & Ko Ltd
# License: GNU Affero General Public License v3 (see file COPYING for details)

from lino.api import dd, _


class EntryStates(dd.Workflow):
    pass


add = EntryStates.add_item
add('10', _("New"), 'new', button_text="☐")
add('20', _("Started"), 'started', button_text="⚒")
add('30', _("Done"), 'done', button_text="☑")
add('40', _("Sleeping"), 'sleeping', button_text="☾")
add('50', _("Cancelled"), 'cancelled', button_text="☒")

Here is the result of above definition:

>>> rt.show(entries.EntryStates)
======= =========== =========== =============
 value   name        text        Button text
------- ----------- ----------- -------------
 10      new         New         ☐
 20      started     Started     ⚒
 30      done        Done        ☑
 40      sleeping    Sleeping    ☾
 50      cancelled   Cancelled   ☒
======= =========== =========== =============

Defining transitions

We define the transitions in a separate module:

# Copyright 2013-2021 Rumma & Ko Ltd
# License: GNU Affero General Public License v3 (see file COPYING for details)

from django.utils.translation import gettext_lazy as _

from .choicelists import EntryStates
from .actions import WakeupEntry, StartEntry, FinishEntry

# EntryStates.new.add_transition(
#     _("Reopen"), required_states='done cancelled')
EntryStates.new.add_transition(WakeupEntry)
EntryStates.started.add_transition(StartEntry)
EntryStates.sleeping.add_transition(required_states="new")
EntryStates.done.add_transition(FinishEntry)
EntryStates.cancelled.add_transition(
    required_states='sleeping started',
    help_text=_("""This is a rather verbose help text for the action
    which triggers transition from 'sleeping' or 'started'
    to 'cancelled'."""),
    icon_name='cancel')

This module is being loaded at startup because its name is defined in the Site's workflows_module.

Defining explicit transition actions

Note how the first argument to add_transition can be either a string or a class. If it is a class, then it defines an action. Here are the actions used in this tutorial:

# Copyright 2013-2018 Rumma & Ko Ltd
# License: GNU Affero General Public License v3 (see file COPYING for details)

from django.utils.translation import gettext_lazy as _
from lino.api import dd, rt
from lino.modlib.notify.actions import NotifyingAction


class StartEntry(dd.ChangeStateAction):
    label = _("Start")
    help_text = _(
        "This action is not allowed when company, body or subject is empty.")
    required_states = 'new cancelled'

    def get_action_permission(self, ar, obj, state):
        # cannot start entries with empty company, subject or body fields
        if not obj.company or not obj.subject or not obj.body:
            return False
        return super(StartEntry, self).get_action_permission(ar, obj, state)


class FinishEntry(StartEntry):
    icon_name = 'accept'
    label = _("Finish")
    required_states = 'new started'
    help_text = _(
        "Inherts from StartEntry and thus is not allowed when company, body or subject is empty."
    )


class WakeupEntry(dd.ChangeStateAction, NotifyingAction):
    label = _("Reopen")

    # label = _("Wake up")
    # required_states = 'sleeping'
    # in our example, waking up an antry will send a notification

    def get_notify_recipients(self, ar, obj):
        if not obj.state.name in ('done', 'cancelled'):
            return
        for u in rt.models.users.User.objects.all():
            yield (u, u.mail_mode)

    def get_notify_subject(self, ar, obj):
        return _("Entry %s has been reactivated!") % obj

Defining an explicit class for transition action is useful when you want your application to decide at runtime whether a transition is available or not.

The get_action_permission method, as used by the StartEntry action in our example. It is called once for every record and thus should not take too much energy. In the example application, you can see that the "Start" action is not shown for entries with one of the company, subject or body fields empty. Follow the link to the API reference for details.

Note that if you test only the user_type of the requesting user, or some value from settings, then you'll rather define a get_view_permission method. That's more efficient because it is called only once for every user user_type at server startup.

Why do I have to declare workflow_state_field?

The workflow_state_field is required if you want your permission handlers to get the state argument.

In models.py there are only a few changes (compared to watch -- Watching database changes), first we need import the EntryStates workflow, and then add a state field to the Entry model:

from .choicelists import EntryStates
...
class Entry(dd.CreatedModified, dd.UserAuthored):
    workflow_state_field = 'state'
    ...
    state = EntryStates.field(blank=True, default='todo')

Showing the workflow actions

And finally we added the workflow_buttons at different places: in the detail_layout of the Entries data view, and in a column_names attribute to EntriesByCompany and MyEntries. Note that workflow_buttons is a virtual field and therefore not automatically shown.