DEV Community

Cover image for Building Durable, AI-Powered Workflows in Django
Anthony Addae
Anthony Addae

Posted on

Building Durable, AI-Powered Workflows in Django

This tutorial walks through a complete local development path for this project:

  1. Start the Django application and its dependencies with Docker Compose.
  2. Install Git pre-commit hooks so quality checks run before each commit.
  3. Create a product through the workflow API.
  4. Follow the product as Temporal calculates a price, waits for approval, updates the price, and publishes the product.

The intended reader is comfortable opening a terminal but new to Docker Compose and Temporal. You do not need to install Python or PostgreSQL directly on your computer: the local Compose setup runs those application dependencies in containers.

What We Are Building

The project is a Django application backed by PostgreSQL. Product onboarding is asynchronous, so the HTTP request does not perform every step itself. Instead, Django creates the product and starts a Temporal workflow.

The local stack contains:

Service Purpose Local address
django Django development server and migrations http://localhost:8000
postgres Application database Internal Compose network
temporal Temporal server localhost:7233
temporal-worker Executes workflow code and activities Internal Compose network
temporal-ui Inspect workflow executions http://localhost:8088
mailpit Captures development email http://localhost:8025

The service definitions live in docker-compose.local.yml. Compose gives services stable names on its internal network, which is why Django connects to postgres and the worker connects to temporal instead of localhost.

Prerequisites

Install these tools first:

Check that they are available:

docker --version
docker compose version
git --version
just --version
Enter fullscreen mode Exit fullscreen mode

Clone the repository and enter it:

git clone https://github.com/klhenams/verbose-spork.git
cd app
Enter fullscreen mode Exit fullscreen mode

Step 1: Check Local Configuration

The Compose file loads environment values from:

.envs/.local/.django
.envs/.local/.postgres
Enter fullscreen mode Exit fullscreen mode

The important local settings are:

  • POSTGRES_HOST=postgres, because PostgreSQL is another Compose service.
  • TEMPORAL_HOST=temporal:7233, supplied to the Django and worker containers.
  • HUGGINGFACEHUB_API_TOKEN, which is optional for this tutorial.

Do not commit real credentials or API tokens. If your checkout does not contain local environment files, create them from your development configuration. The Django file can contain an empty Hugging Face token while learning the workflow; the application has a deterministic cost-based fallback.

Step 2: Start Docker Compose

The repository's justfile selects docker-compose.local.yml automatically:

just up
Enter fullscreen mode Exit fullscreen mode

Under the hood, this runs:

docker compose -f docker-compose.local.yml up -d --remove-orphans
Enter fullscreen mode Exit fullscreen mode

The first start may take a while because Docker builds the Django and PostgreSQL images and downloads Temporal images. The Django container runs python manage.py migrate before starting the development server, so the database schema is prepared automatically.

Check the running containers:

docker compose ps
Enter fullscreen mode Exit fullscreen mode

Open these pages in a browser:

If the application is still starting, inspect the logs:

just logs django
just logs temporal-worker
Enter fullscreen mode Exit fullscreen mode

The worker should eventually log that it is listening on the product-tasks task queue. A Temporal worker must be running for workflow activities to execute; starting only Django is not enough.

Step 3: Install Git Pre-Commit Hooks

pre-commit runs configured checks against staged files before Git creates a commit. This repository declares its hooks in .pre-commit-config.yaml and installs the development dependency from pyproject.toml.

Install the hook inside the Django container so the host machine does not need the project's Python version:

docker compose run --rm django pre-commit install
Enter fullscreen mode Exit fullscreen mode

Run all hooks once against the whole repository:

docker compose run --rm django pre-commit run --all-files
Enter fullscreen mode Exit fullscreen mode

The configuration checks common file problems, applies Django upgrades, runs Ruff linting and formatting, formats pyproject.toml, and validates Django templates with djLint. Some hooks can modify files. Review those changes, stage them, and run the command again until it passes.

To run the checks manually on only staged files:

docker compose run --rm django pre-commit run
Enter fullscreen mode Exit fullscreen mode

The configuration deliberately excludes documentation and migrations from the hook selection. That does not mean documentation cannot be reviewed; it means the project avoids applying code-formatting hooks to those paths.

Step 4: Run the Tests

The justfile provides a containerized pytest command:

just pytest
Enter fullscreen mode Exit fullscreen mode

For the pricing activity tests only:

just pytest app/products/tests/test_workflow_activities.py
Enter fullscreen mode Exit fullscreen mode

The pricing activity has two paths:

  1. With HUGGINGFACEHUB_API_TOKEN, it asks the configured Hugging Face model for a structured PriceSuggestion.
  2. Without a token, it uses a fallback of cost * 1.35, rounded to two decimal places.

That fallback makes the local workflow usable without an external model account.

Step 5: Understand the Product Creation Endpoint

The workflow-specific endpoint is registered as workflows in the product API router. Because the project includes the API router below /api/, create a product with:

POST http://localhost:8000/api/workflows/
Enter fullscreen mode Exit fullscreen mode

The endpoint requires authentication. Use the API documentation at http://localhost:8000/api/docs/ or obtain a DRF token from the project's authentication endpoint before sending the request.

The request body should include the product fields below. category is an existing category ID:

{
  "name": "Portable Monitor",
  "description": "A lightweight second screen for a laptop.",
  "sku": "MONITOR-PORTABLE-001",
  "price": "0.00",
  "cost": "100.00",
  "stock_quantity": 12,
  "low_stock_threshold": 3,
  "category": 1
}
Enter fullscreen mode Exit fullscreen mode

The initial price is allowed to be a placeholder. The workflow will calculate a suggestion and later write the selected price. The create view saves the product with pending_pricing and starts a workflow with the ID:

product-onboarding-{product_id}
Enter fullscreen mode Exit fullscreen mode

For example, product 42 runs as product-onboarding-42 on Temporal task queue product-tasks.

Step 6: Follow the Workflow Sequentially

The workflow is implemented in app/products/workflows/workflows.py. Its activities are in app/products/workflows/activities.py.

1. Calculate a suggested price

The worker executes calculate_suggested_market_price.

With no Hugging Face token and a cost of 100.00, the fallback suggestion is:

100.00 * 1.35 = 135.00 GHS
Enter fullscreen mode Exit fullscreen mode

The activity returns a structured result containing the suggested price, currency, confidence score, reasoning, and source. If the external model fails, the activity logs the exception and uses the fallback instead.

2. Inspect the suggestion

The API can query the running workflow without changing its state:

GET http://localhost:8000/api/workflows/{product_id}/pricing-suggestion/
Enter fullscreen mode Exit fullscreen mode

Example response:

{
  "suggestion": {
    "suggested_price": 135.0,
    "recommended_margin": "35.0",
    "currency": "GHS",
    "confidence_score": 0.0,
    "reasoning": "Fallback pricing based on product cost.",
    "source": "cost_fallback"
  }
}
Enter fullscreen mode Exit fullscreen mode

The workflow includes a short ten-second pause after pricing so the execution is easy to observe in the Temporal UI. That delay is for demonstration and is not required by the business process.

3. Approve or override the price

The workflow waits for a signal named approve_suggested_price. Send the suggested price as-is or choose another value:

POST http://localhost:8000/api/workflows/{product_id}/approve-price/
Enter fullscreen mode Exit fullscreen mode

Request body:

{
  "price": "129.99"
}
Enter fullscreen mode Exit fullscreen mode

The API translates this request into a Temporal signal. The workflow records the selected price and continues. This is a useful distinction:

  • A query reads workflow state.
  • A signal sends an event that changes workflow state.

If no approval arrives before the configured timeout, the workflow automatically selects the suggested price. The default timeout is 24 hours.

4. Update the product price

After approval or timeout, update_product_price stores the final price in PostgreSQL. The product remains controlled by the workflow rather than being published immediately by the HTTP request.

5. Publish with retries

The workflow calls publish_product with a Temporal retry policy:

  • Maximum of three attempts
  • Two-second initial interval
  • Exponential backoff with a coefficient of 2.0

When publishing succeeds, the activity changes the product status to active, and the workflow returns:

{
  "status": "PUBLISHED",
  "final_price": 129.99,
  "was_auto_approved": false
}
Enter fullscreen mode Exit fullscreen mode

Step 7: See the Compensation Path

To exercise the failure path, include the test-only field below when creating a product:

{
  "name": "Failure Demo",
  "description": "Used to demonstrate workflow compensation.",
  "sku": "FAILURE-DEMO-001",
  "price": "0.00",
  "cost": "10.00",
  "stock_quantity": 1,
  "low_stock_threshold": 1,
  "category": 1,
  "test_should_fail_publish": true,
  "test_timeout_seconds": 20
}
Enter fullscreen mode Exit fullscreen mode

The publish activity raises an error deliberately. Temporal retries it three times. When all attempts fail, the workflow executes revert_product_status, which changes the database status to failed_publish, and returns a FAILED_PUBLISH result.

This is a small Saga pattern: after earlier work has changed state, a compensation activity restores a meaningful failure state when a later operation cannot complete.

The test_* fields are passed through by the current API view to make demonstrations and tests predictable. Treat them as development/testing controls, not as production business inputs.

Observe Everything in Temporal UI

Open http://localhost:8088 and find the workflow named product-onboarding-{product_id}. You can inspect:

  • The workflow status
  • The activity attempts and retry history
  • The waiting period for human approval
  • The final return value
  • The failure and compensation sequence

This is often easier to understand than reading logs because Temporal shows the workflow's event history as a durable sequence.

Stopping and Resetting the Environment

Stop the containers but keep the PostgreSQL volume:

just down
Enter fullscreen mode Exit fullscreen mode

Stop the containers and delete local volumes, including the database data:

just prune
Enter fullscreen mode Exit fullscreen mode

Use just prune carefully. The next just up starts with an empty database and will need to run migrations and recreate any sample data.

Troubleshooting

The workflow never advances

Check the worker:

just logs temporal-worker
Enter fullscreen mode Exit fullscreen mode

The worker must connect to temporal:7233 and register the product-tasks queue. If only the Django container is running, the workflow can start but its activities cannot be picked up.

Django cannot connect to PostgreSQL

Inside Compose, use postgres as the database host, not localhost. Check that the database health check has passed:

docker compose ps postgres
Enter fullscreen mode Exit fullscreen mode

The pricing suggestion is not from Hugging Face

That is expected when HUGGINGFACEHUB_API_TOKEN is empty or the model request fails. The cost-based fallback is intentionally part of the local implementation.

Pre-commit changes a file

Review and stage the change, then run the hook again:

git diff
git add -A
docker compose run --rm django pre-commit run
Enter fullscreen mode Exit fullscreen mode

What to Read Next

The central idea is simple: Django accepts the request and records intent, while Temporal coordinates the long-running work, human decision, retries, and compensation. Docker Compose makes that entire system reproducible on a developer laptop.

Top comments (0)