Skip to content

Version: Arctyk ITSM v0.6.0+ Last Updated: January 2026

Workflow Engine

The Arctyk ITSM Workflow Engine provides automated, rule-based processing for tickets and business logic.


Overview

The workflow engine handles:

  • State Transitions - Control how tickets move between statuses
  • Business Rules - Apply conditions and constraints to ticket lifecycle
  • Automation - Trigger actions based on ticket events
  • Validation - Enforce workflow rules at form and model levels

Ticket Status & Categories

Status Categories

Tickets are organized into three broad categories:

Category Meaning Statuses
todo Work not yet started New, Open
in_progress Work currently happening In Progress, On Hold
done Work completed Resolved, Closed

Available Statuses

  • New - Newly created ticket
  • Open - Ready for work
  • In Progress - Actively being worked on
  • On Hold - Temporarily paused
  • Blocked - Blocked by external dependency
  • Resolved - Issue resolved, awaiting closure
  • Closed - Ticket completed

Workflow Transitions

Transition Rules

Transitions are defined by category, not individual status. From any status in a category, you can transition to specific next categories.

Todo (New, Open)
  ├→ In Progress
  ├→ On Hold / Blocked
  └→ Done (Resolved)

In Progress
  ├→ Resolved
  ├→ Todo (Open)
  └→ Blocked

Done (Resolved)
  ├→ Closed
  └→ Todo (Open, if reopening needed)

Closed
  └→ (Terminal - no further transitions)

Transition UI Labels

The UI presents transitions with user-friendly labels:

From Label To
Todo Start Progress In Progress
In Progress Resolve Resolved
Resolved Close Closed
Done Reopen Open

Workflow Architecture

Components

1. Constants (tickets/constants.py)

Defines status and category mappings:

# Status to category mapping
STATUS_TO_CATEGORY = {
    'new': 'todo',
    'open': 'todo',
    'in_progress': 'in_progress',
    'on_hold': 'in_progress',
    'blocked': 'in_progress',
    'resolved': 'done',
    'closed': 'done',
}

# Transition definitions for UI
WORKFLOW_TRANSITIONS = {
    'todo': {
        'in_progress': {'label': 'Start Progress', 'style': 'primary'},
        'done': {'label': 'Resolve', 'style': 'success'},
    },
    'in_progress': {
        'todo': {'label': 'Reopen', 'style': 'warning'},
        'done': {'label': 'Resolve', 'style': 'success'},
    },
    'done': {
        'todo': {'label': 'Reopen', 'style': 'warning'},
        'done': {'label': 'Close', 'style': 'secondary'},
    },
}

2. Workflow Module (tickets/workflows.py)

Implements transition validation:

TICKET_STATUS_TRANSITIONS = {
    'todo': {'in_progress', 'waiting', 'closed'},
    'in_progress': {'waiting', 'resolved', 'closed'},
    'waiting': {'in_progress', 'resolved'},
    'resolved': {'closed', 'in_progress'},
    'closed': set(),  # Terminal
}

def is_valid_transition(old_status, new_status):
    """Check if transition is allowed."""
    if old_status == new_status:
        return True
    return new_status in TICKET_STATUS_TRANSITIONS.get(old_status, set())

3. Form Validation (tickets/forms.py)

Form-level transition enforcement:

class TicketForm(ModelForm):
    def clean_status(self):
        new_status = self.cleaned_data.get('status')

        # Get current ticket status
        if self.instance.pk:
            current = Ticket.objects.get(pk=self.instance.pk)
            old_status = current.status

            # Check if transition is valid
            if not is_valid_transition(old_status, new_status):
                raise ValidationError(
                    f'Cannot transition from {old_status} to {new_status}'
                )

        return new_status

4. Model Enforcement (tickets/models.py)

Model-level validation:

class Ticket(Model):
    def save(self, *args, **kwargs):
        # Set status category
        self.status_category = STATUS_TO_CATEGORY.get(self.status, 'todo')

        # If updating existing ticket, validate transition
        if self.pk:
            old = Ticket.objects.get(pk=self.pk)
            if old.status != self.status:
                if not is_valid_transition(old.status, self.status):
                    raise ValidationError('Invalid status transition')

        super().save(*args, **kwargs)

    def get_available_transitions(self):
        """Get valid next statuses."""
        category = self.status_category
        transitions = WORKFLOW_TRANSITIONS.get(category, {})
        return list(transitions.keys())

Using the Workflow Engine

Check Valid Transitions

from tickets.workflows import is_valid_transition
from tickets.models import Ticket

ticket = Ticket.objects.get(pk=1)

# Check if transition is valid
if is_valid_transition(ticket.status, 'in_progress'):
    ticket.status = 'in_progress'
    ticket.save()
else:
    print('Invalid transition!')

Get Available Actions

ticket = Ticket.objects.get(pk=1)

# In template
{{ ticket.get_available_transitions }}  # ['in_progress', 'resolved']

Apply Transition with Context

from tickets.workflow import apply_transition

ticket = Ticket.objects.get(pk=1)

# Apply transition with additional context
apply_transition(
    ticket=ticket,
    new_status='in_progress',
    updated_by=request.user,
    comment='Starting work on this issue'
)

Automation & Business Rules

Triggered Actions

The workflow can trigger automatic actions:

@receiver(post_save, sender=Ticket)
def on_ticket_status_change(sender, instance, **kwargs):
    """Triggered when ticket status changes."""

    # Automatically assign to default group
    if instance.status == 'new':
        instance.assigned_to = Group.objects.get(name='Support').users.first()
        instance.save()

    # Send notification
    if instance.status == 'resolved':
        notify_requester(instance, 'Your ticket has been resolved')

    # Archive old closed tickets
    if instance.status == 'closed':
        archive_if_old(instance, days=30)

Conditional Workflow Rules

Apply business logic based on ticket properties:

def can_close_ticket(ticket):
    """Check if ticket can be closed."""

    # Must be resolved first
    if ticket.status != 'resolved':
        return False

    # Must have no open subtasks
    if ticket.subtasks.filter(status='open').exists():
        return False

    # Cannot close high-priority tickets without approval
    if ticket.priority == 'critical':
        return ticket.requires_review

    return True

Recurring Tickets

Recurring Task Templates

Recurring tickets are created by RecurringTaskTemplate and executed by Celery:

class RecurringTaskTemplate(Model):
    name = CharField(max_length=255)
    pattern = CharField(choices=[
        ('daily', 'Daily'),
        ('weekly', 'Weekly'),
        ('monthly', 'Monthly'),
    ])
    template_ticket = ForeignKey(Ticket, on_delete=CASCADE)
    is_active = BooleanField(default=True)

Celery Task

@shared_task
def create_recurring_tickets():
    """Run every minute, creates tickets from active templates."""

    for template in RecurringTaskTemplate.objects.filter(is_active=True):
        if should_create_ticket(template):
            ticket = Ticket.objects.create(
                title=template.template_ticket.title,
                description=template.template_ticket.description,
                priority=template.template_ticket.priority,
            )

            RecurringTicketRun.objects.create(
                template=template,
                ticket=ticket,
                run_at=timezone.now()
            )

Lifecycle Timestamps

Track ticket lifecycle milestones:

class Ticket(Model):
    # Timestamps
    created_at = DateTimeField(auto_now_add=True)
    updated_at = DateTimeField(auto_now=True)

    # Lifecycle milestones (v0.6.0+)
    first_responded_at = DateTimeField(null=True, blank=True)
    resolved_at = DateTimeField(null=True, blank=True)
    closed_at = DateTimeField(null=True, blank=True)

Automatic Lifecycle Tracking

def save(self, *args, **kwargs):
    # Track first response
    if self.status in ['in_progress', 'resolved'] and not self.first_responded_at:
        self.first_responded_at = timezone.now()

    # Track resolution
    if self.status == 'resolved' and not self.resolved_at:
        self.resolved_at = timezone.now()

    # Track closure
    if self.status == 'closed' and not self.closed_at:
        self.closed_at = timezone.now()

    super().save(*args, **kwargs)

SLA Tracking

Track SLA compliance with lifecycle data:

class Ticket(Model):
    # SLA targets (v0.6.0+)
    response_target = DateTimeField(null=True, blank=True)
    resolution_target = DateTimeField(null=True, blank=True)

def check_sla_breach(ticket):
    """Check if ticket has breached SLA."""
    now = timezone.now()

    # Response SLA
    if not ticket.first_responded_at and now > ticket.response_target:
        return 'Response SLA breached'

    # Resolution SLA
    if not ticket.closed_at and now > ticket.resolution_target:
        return 'Resolution SLA breached'

    return None

Best Practices

1. Enforce Rules at Multiple Levels

  • ✅ Form validation (prevents bad data submission)
  • ✅ Model validation (enforces at persistence layer)
  • ✅ API validation (protects programmatic access)

2. Use Constants for State

# ✅ Good
if ticket.status == STATUS_IN_PROGRESS:
    ...

# ❌ Bad
if ticket.status == 'in_progress':  # Magic string
    ...

3. Document Transitions

Keep workflows.py and constants.py updated when adding new statuses or transitions.

4. Test Edge Cases

def test_cannot_transition_from_closed():
    """Closed tickets should not transition further."""
    ticket = Ticket.objects.create(..., status='closed')

    with self.assertRaises(ValidationError):
        ticket.status = 'open'
        ticket.save()

5. Handle Async Operations

Use Celery for long-running workflow operations:

@shared_task
def process_bulk_status_change(ticket_ids, new_status):
    """Bulk status change in background."""
    for ticket_id in ticket_ids:
        ticket = Ticket.objects.get(pk=ticket_id)
        if is_valid_transition(ticket.status, new_status):
            ticket.status = new_status
            ticket.save()