WorkflowView
The WorkflowView integrates django-fsm-2 state machines
with the crud-views framework. It renders a form that allows users to execute FSM transitions on a
model instance, enforces comment requirements per transition, and maintains a full audit history via
the WorkflowInfo model.
Installation
Install the workflow optional dependency group:
pip install django-crud-views[workflow]
Add the required apps to INSTALLED_APPS:
INSTALLED_APPS = [
...
"django.contrib.contenttypes", # required for audit log
"django_fsm",
"crud_views_workflow.apps.CrudViewsWorkflowConfig",
...
]
Run migrations to create the WorkflowInfo audit table:
python manage.py migrate
Basic Usage
1. Define the model
Mix WorkflowModelMixin into your model and define FSM transitions using django-fsm-2:
from django.db import models
from django.utils.translation import gettext_lazy as _
from django_fsm import FSMField, transition
from crud_views_workflow.lib.enums import WorkflowComment
from crud_views_workflow.lib.enums import BadgeEnum
from crud_views_workflow.lib.mixins import WorkflowModelMixin
class CampaignState(models.TextChoices):
NEW = "new", _("New")
ACTIVE = "active", _("Active")
SUCCESS = "success", _("Success")
CANCELED = "canceled", _("Cancelled")
ERROR = "error", _("Error")
class Campaign(WorkflowModelMixin, models.Model):
STATE_CHOICES = CampaignState
STATE_BADGES = {
CampaignState.NEW: BadgeEnum.LIGHT,
CampaignState.ACTIVE: BadgeEnum.INFO,
CampaignState.SUCCESS: BadgeEnum.PRIMARY,
CampaignState.CANCELED: BadgeEnum.WARNING,
CampaignState.ERROR: BadgeEnum.DANGER,
}
name = models.CharField(max_length=128)
state = FSMField(default=CampaignState.NEW, choices=CampaignState.choices)
@transition(
field=state,
source=CampaignState.NEW,
target=CampaignState.ACTIVE,
on_error=CampaignState.ERROR,
custom={"label": _("Activate"), "comment": WorkflowComment.NONE},
)
def wf_activate(self, request=None, by=None, comment=None):
pass
@transition(
field=state,
source=CampaignState.ACTIVE,
target=CampaignState.SUCCESS,
on_error=CampaignState.ERROR,
custom={"label": _("Done"), "comment": WorkflowComment.OPTIONAL},
)
def wf_done(self, request=None, by=None, comment=None):
pass
@transition(
field=state,
source=CampaignState.NEW,
target=CampaignState.CANCELED,
on_error=CampaignState.ERROR,
custom={"label": _("Cancel"), "comment": WorkflowComment.REQUIRED},
)
def wf_cancel_new(self, request=None, by=None, comment=None):
pass
2. Define the ViewSet and views
from crud_views.lib.crispy import CrispyModelViewMixin
from crud_views.lib.views import MessageMixin
from crud_views.lib.viewset import ViewSet
from crud_views_workflow.lib.forms import WorkflowForm
from crud_views_workflow.lib.views import WorkflowView
from .models import Campaign
cv_campaign = ViewSet(model=Campaign, name="campaign")
class CampaignWorkflowForm(WorkflowForm):
class Meta(WorkflowForm.Meta):
model = Campaign
class CampaignWorkflowView(CrispyModelViewMixin, MessageMixin, WorkflowView):
cv_context_actions = ["list", "detail", "workflow"]
cv_viewset = cv_campaign
form_class = CampaignWorkflowForm
3. Register URLs
# urls.py
urlpatterns = cv_campaign.urlpatterns
View Classes
| Class | Description |
|---|---|
WorkflowView |
Base workflow view without permission check |
WorkflowViewPermissionRequired |
Workflow view requiring change permission |
Both inherit from CustomFormView (a DetailView with form handling). They fetch the object
from the URL pk parameter, render the transition form, and process the selected transition on POST.
WorkflowViewPermissionRequired adds CrudViewPermissionRequiredMixin with cv_permission = "change".
Authenticated users without the required permission receive a 403; anonymous users are redirected to
the login page.
Use WorkflowViewPermissionRequired for production views:
from crud_views_workflow.lib.views import WorkflowViewPermissionRequired
class CampaignWorkflowView(CrispyModelViewMixin, MessageMixin, WorkflowViewPermissionRequired):
cv_context_actions = ["list", "detail", "workflow"]
cv_viewset = cv_campaign
form_class = CampaignWorkflowForm
WorkflowModelMixin
WorkflowModelMixin is a model mixin that provides workflow-related properties and helpers.
Required class attributes
| Attribute | Description |
|---|---|
STATE_CHOICES |
A models.TextChoices subclass defining all state values and labels |
STATE_BADGES |
Dict mapping state values to BadgeEnum badge colours |
Optional class attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
STATE_BADGE_DEFAULT |
BadgeEnum |
BadgeEnum.INFO |
Badge colour used for states not present in STATE_BADGES |
COMMENT_DEFAULT |
WorkflowComment |
WorkflowComment.NONE |
Fallback comment requirement for transitions that omit comment from their custom dict |
Properties and methods
| Member | Description |
|---|---|
state_name |
Human-readable name for the current state |
state_badge |
HTML <span> badge for the current state |
get_state_name(state) |
Human-readable name for a given state value |
get_state_badge(state) |
HTML badge for a given state value |
workflow_data |
List of dicts with full transition history (timestamp, user, states, label, comment) |
workflow_get_possible_transitions(user) |
Available transitions for a user as [(name, label, comment_type)] |
workflow_has_any_possible_transition(user) |
True if any transitions are available |
workflow_has_transition(user, transition) |
True if a specific transition is available |
workflow_get_transition_label(name) |
Human-readable label for a transition name |
workflow_get_transition_label_map |
Cached dict of all transition names to labels |
workflow_get_form_kwargs(user) |
Builds kwargs for WorkflowForm (choices, transition_comments) |
WorkflowComment
Each transition declares its comment requirement via the custom dict on the @transition decorator.
Import WorkflowComment from crud_views_workflow.lib.enums:
from crud_views_workflow.lib.enums import WorkflowComment
@transition(
field=state,
source=CampaignState.NEW,
target=CampaignState.CANCELED,
custom={"label": _("Cancel"), "comment": WorkflowComment.REQUIRED},
)
def wf_cancel_new(self, ...):
...
| Value | Behaviour |
|---|---|
WorkflowComment.NONE |
Comment field is hidden |
WorkflowComment.OPTIONAL |
Comment field is shown but not required |
WorkflowComment.REQUIRED |
Comment field is shown and must be filled |
When a transition does not include comment in its custom dict, WorkflowModelMixin.COMMENT_DEFAULT
is used as the fallback. Override it on your model class to change the default for all such transitions:
class Campaign(WorkflowModelMixin, models.Model):
COMMENT_DEFAULT = WorkflowComment.OPTIONAL
...
BadgeEnum
BadgeEnum is a StrEnum covering all Bootstrap contextual colours. Import it alongside WorkflowModelMixin:
from crud_views_workflow.lib.enums import BadgeEnum
from crud_views_workflow.lib.mixins import WorkflowModelMixin
| Value | Bootstrap class |
|---|---|
BadgeEnum.PRIMARY |
primary |
BadgeEnum.SECONDARY |
secondary |
BadgeEnum.SUCCESS |
success |
BadgeEnum.DANGER |
danger |
BadgeEnum.WARNING |
warning |
BadgeEnum.INFO |
info |
BadgeEnum.LIGHT |
light |
BadgeEnum.DARK |
dark |
States not listed in STATE_BADGES fall back to STATE_BADGE_DEFAULT (default: BadgeEnum.INFO).
WorkflowForm
WorkflowForm is the base form class. Subclass it and set Meta.model:
class CampaignWorkflowForm(WorkflowForm):
class Meta(WorkflowForm.Meta):
model = Campaign
The form contains two fields:
| Field | Description |
|---|---|
transition |
RadioSelect of available transitions (dynamically populated) |
comment |
Optional/required Textarea (visibility controlled by comment requirement) |
The form's clean() validates that a comment is provided when Comment.REQUIRED is selected.
WorkflowInfo (audit log)
Every successful transition creates a WorkflowInfo record:
| Field | Description |
|---|---|
transition |
The transition method name (e.g. "wf_activate") |
state_old |
State value before the transition |
state_new |
State value after the transition |
comment |
User-provided comment, or None |
user |
The User who triggered the transition |
timestamp |
Date/time of the transition |
data |
Optional JSON data returned by the transition method |
workflow_object_pk |
CharField(max_length=255) — the PK of the transitioned object (supports UUID, int, and string PKs) |
workflow_object_content_type |
FK to ContentType of the transitioned object |
workflow_object |
Generic FK combining workflow_object_content_type and workflow_object_pk |
An index on (workflow_object_pk, workflow_object_content_type) is defined for efficient history lookups.
Access the history via WorkflowModelMixin.workflow_data, which returns a list of dicts enriched
with badge HTML and human-readable labels.
WorkflowView configuration
| Attribute | Type | Default | Description |
|---|---|---|---|
cv_key |
str |
"workflow" |
ViewSet key for this view |
cv_path |
str |
"workflow" |
URL path segment (e.g. /<pk>/workflow/) |
template_name |
str |
"crud_views_workflow/view_workflow.html" |
Template |
form_class |
Form |
— | Must be set to a WorkflowForm subclass |
cv_viewset |
ViewSet |
— | The ViewSet this view belongs to |
cv_transition_label |
str |
"Select a possible workflow action to take" |
Transition field label |
cv_transition_help_text |
str |
None |
Transition field help text |
cv_comment_label |
str |
"Please provide a comment for your workflow step" |
Comment field label |
cv_comment_help_text |
str |
None |
Comment field help text |
System checks
WorkflowView participates in the crud-views check framework. Checks are run via
ViewSet.checks() for every registered view and report configuration errors early.
WorkflowView.checks() inherits all parent checks (e.g. cv_key, cv_path, template
attributes from CrudView) and adds:
| ID | What is checked |
|---|---|
E230 |
form_class is set (not None) |
E231 |
cv_transition_label is not None |
E232 |
cv_comment_label is not None |
E233 |
The model associated with cv_viewset extends WorkflowModelMixin |
E234 |
STATE_CHOICES is set on the model |
E235 |
STATE_BADGES is set on the model |
WorkflowViewPermissionRequired additionally inherits the E202 check for cv_permission
from CrudViewPermissionRequiredMixin.
on_transition hook
Override on_transition to run custom logic after a successful transition:
class CampaignWorkflowView(CrispyModelViewMixin, MessageMixin, WorkflowView):
cv_viewset = cv_campaign
form_class = CampaignWorkflowForm
def on_transition(self, info, transition, state_old, state_new, comment, user, data):
# send notification, trigger async task, etc.
send_notification(self.object, state_new, user)
| Parameter | Description |
|---|---|
info |
The newly created WorkflowInfo instance |
transition |
Transition method name (str) |
state_old |
Previous state value |
state_new |
New state value |
comment |
Comment string or None |
user |
The requesting User |
data |
Return value of the transition method (or None) |
Displaying state in ListView and DetailView
Use state_badge (the HTML badge property) in your table and detail view:
import django_tables2 as tables
from crud_views.lib.table import Table, LinkDetailColumn
from django_object_detail import PropertyConfig
class CampaignTable(Table):
id = LinkDetailColumn()
name = tables.Column()
state = tables.Column(accessor="state_badge")
class CampaignDetailView(DetailViewPermissionRequired):
cv_viewset = cv_campaign
property_display = [
{
"title": "Properties",
"properties": [
"name",
PropertyConfig(path="state_badge", title=_("State")),
],
},
]