Skip to content

Add Workflow to a Model ​

In this tutorial, we add a Workflow to the Product model from Start Building. A product starts as a draft, and a publish transition moves it to published. By the end, the API reports each product's state and executes transitions, and the client shows transition buttons.

We work as the Start Building user, you@example.com. Its group grants the inventory permissions, and we add a second group for the workflow.

Wire Up Workflow URLs ​

vueda.workflow is in Django: INSTALLED_APPS by default, and its migrations ran with your first migrate. The project templates route the vueda.info and vueda.user URLs but leave out vueda.workflow and vueda.history. Add the workflow URL patterns and the history URL patterns to server/config/urls.py:

python
from django.urls import include, path

from vueda.history.urls import urlpatterns as vueda_history_urls  
from vueda.info.urls import urlpatterns as vueda_info_urls
from vueda.user.urls import urlpatterns as vueda_user_urls
from vueda.workflow.urls import urlpatterns as vueda_workflow_urls  

urlpatterns = [
    path(
        "routes/",
        include(
            [
                path("", include(vueda_info_urls)),
                path("", include(vueda_user_urls)),
                path("", include(vueda_workflow_urls)),  
                path("", include(vueda_history_urls)),  
                path("", include("your_project.urls")),
            ]
        ),
    ),
]

This adds the workflow API under routes/vueda.workflow/workflows/<app_label>/<model>/. It also adds the workflow state history under routes/vueda.history/workflow-state-history/<app_label>/<model>/<object_id>/, which lists the states that an object has been in. The client's storeWorkflow.fetchObjectHistory requests this path. While Django: DEBUG is on, it also adds the workflow management views under routes/vueda.workflow/.

Enable Workflow on the Model ​

In server/your_project/inventory/models.py, add class Vueda with a Workflow class to Product:

python
class Product(VuedaModel):
    name = models.CharField(max_length=255)
    sku = models.CharField(max_length=64, unique=True)
    description = models.TextField(blank=True)

    class Vueda:  
        class Workflow:  
            enabled = True

    class Meta(BaseModelMeta):
        ordering = ["name", "id"]

Product is now a Workflow-Enabled Model. VUEDA gives each product an Object State record when it is saved. The product serializer and filterset gain the workflow fields and the workflow_state filter. The model needs no new migration, because object states live in a vueda.workflow table.

Until the model has a workflow definition, saving a Product raises WorkflowNotConfiguredError. We create the definition next.

Create the Workflow Definition ​

A workflow definition has four parts:

From server/, open a Django shell:

console
uv run python manage.py shell

Create the workflow, its two states, the initial state, and two transitions:

python
from django.contrib.contenttypes.models import ContentType
from vueda.workflow.models import (
    InitialState,
    State,
    Transition,
    TransitionSource,
    Workflow,
)

from your_project.inventory.models import Product

ct = ContentType.objects.get_for_model(Product)

workflow = Workflow.objects.create(
    code="product",
    name="Product Workflow",
    content_type=ct,
)

draft = State.objects.create(workflow=workflow, code="draft", name="Draft")
published = State.objects.create(workflow=workflow, code="published", name="Published")

InitialState.objects.create(workflow=workflow, state=draft)

publish = Transition.objects.create(
    workflow=workflow, code="publish", name="Publish", target=published
)
TransitionSource.objects.create(transition=publish, source=draft)

unpublish = Transition.objects.create(
    workflow=workflow, code="unpublish", name="Unpublish", target=draft
)
TransitionSource.objects.create(transition=unpublish, source=published)

Keep the shell open for the next step.

The Starter Kit product from Start Building has no object state yet, because we saved it before the workflow existed. In a second terminal, from server/, give it the initial state with Command:

console
uv run python manage.py backfillworkflowstates inventory.Product
# Expect: inventory.Product: created 1 object state(s).

Grant Workflow Permissions ​

Until the workflow has a Workflow Permission, no user can list or execute its transitions. A transition with no Transition Permission is hidden from every user. Workflow as a Permission Overlay explains both gates.

In the same shell, create one workflow permission and one permission per transition. Then grant them to a group, and add our user to it:

python
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from vueda.workflow.models import TransitionPermission, WorkflowPermission

read_product = Permission.objects.get(content_type=ct, codename="read_product")
update_product = Permission.objects.get(content_type=ct, codename="update_product")
read_workflow = Permission.objects.get(
    content_type__app_label="vueda_workflow", codename="read_workflow"
)

# Users need inventory.read_product to use this workflow.
WorkflowPermission.objects.create(workflow=workflow, permission=read_product)

# Users need inventory.update_product to take each transition.
for transition in workflow.transitions.all():
    TransitionPermission.objects.create(transition=transition, permission=update_product)

editors, _ = Group.objects.get_or_create(name="Product Editors")
editors.permissions.add(read_workflow, read_product, update_product)
get_user_model().objects.get(email="you@example.com").groups.add(editors)

The transition endpoints also check vueda_workflow.read_workflow, so the group grants it. The group also grants the two inventory permissions that the rows name, so it holds everything this workflow needs.

Verify the API ​

Log in with curl as in Start Building, so $COOKIE_JAR and $CSRF_TOKEN are set. Then request the model's Permitted Transitions:

console
curl -b $COOKIE_JAR \
  http://localhost:8000/routes/vueda.workflow/workflows/inventory/product/permitted_transitions/
# Expect: 200 with [{"code": "publish", "name": "Publish"}, {"code": "unpublish", "name": "Unpublish"}]

Create a product. It starts in the draft state.

console
curl -b $COOKIE_JAR -c $COOKIE_JAR \
  -H "Content-Type: application/json" \
  -H "X-CSRFToken: $CSRF_TOKEN" \
  -X POST http://localhost:8000/routes/inventory/product/ \
  -d '{"name":"Workflow Kit","sku":"WORKFLOW-001"}'
# Expect: 201 with the created object
PRODUCT_ID=3  # replace with the "id" from the response

Read the product's state and the transitions it can take from that state:

console
curl -b $COOKIE_JAR \
  http://localhost:8000/routes/vueda.workflow/workflows/inventory/product/object-state/$PRODUCT_ID/
# Expect: 200 with {"state": {"code": "draft", "name": "Draft"}, ...}

curl -b $COOKIE_JAR \
  http://localhost:8000/routes/vueda.workflow/workflows/inventory/product/object-transitions/$PRODUCT_ID/
# Expect: 200 with [{"code": "publish", "name": "Publish"}]

Execute the publish transition by sending its code as transition_code:

console
curl -b $COOKIE_JAR -c $COOKIE_JAR \
  -H "Content-Type: application/json" \
  -H "X-CSRFToken: $CSRF_TOKEN" \
  -X PATCH \
  http://localhost:8000/routes/vueda.workflow/workflows/inventory/product/execute-transition/$PRODUCT_ID/ \
  -d '{"transition_code": "publish"}'
# Expect: 200 with {"new_state": {"code": "published", ...}, "new_transitions": [{"code": "unpublish", "name": "Unpublish"}]}

The product is now published, and unpublish is the one transition it can take.

Verify in the Browser ​

With the client running, log in as you@example.com and reload the page, so the client reads the new permissions. Open the read view of the Starter Kit product. A Publish button appears beside the standard CRUD actions. On the Workflow Kit product, which we published, the button reads Unpublish.

The list view builds its transition buttons from the model's permitted transitions, and a detail view builds them from the object's Valid Transitions. Action Contract and Availability describes where each view's buttons come from.

What's Next ​