amer.catic@projectvisit.org dagb@projectvisit.org +46 31 322 7207

A Hands-On Tutorial: Creating a Digital Kanban Board with Python

A digital Kanban board gives a project team a shared view of work, priorities and bottlenecks. In this tutorial, you will build a small browser-based board with Python and Flask. It will support three workflow columns, draggable cards, new work items and simple JSON file storage.

The example is deliberately lightweight, making it suitable for a prototype, classroom exercise or internal improvement project. It also reflects the practical focus of Celean research work on digital visual planning, lean processes and project management. You can later replace the file-based storage with a database or connect the board to an enterprise system.

Define The Workflow Before Writing Code

Kanban works best when the columns represent genuine stages in a team’s process. A basic workflow might contain “To Do”, “In Progress” and “Done”, but the labels should match the work being managed. A software team could use “Ready”, “Building”, “Review” and “Released”, while a manufacturing project might use “Planned”, “In Production”, “Quality Check” and “Complete”.

For this tutorial, the board will use three columns:

COLUMNS = ["To Do", "In Progress", "Done"]

Each card will have an ID, title, description, owner and status. The status determines its column. Keeping the data model small makes the prototype easier to understand and gives you a clean base for future features such as due dates, work-in-progress limits and activity history.

Create a project folder and install Flask in a virtual environment:

mkdir kanban-python
cd kanban-python
python -m venv .venv
source .venv/bin/activate
pip install flask

On Windows, activate the environment with .venv\Scripts\activate. Save the application below as app.py. The board will run locally at http://127.0.0.1:5000, so no public hosting or cloud account is required while you are experimenting.

Create The Python Data Layer

The application stores cards in cards.json. This is appropriate for a demonstration or a very small team, though a production system should use SQLite, PostgreSQL or an existing enterprise platform. The functions below load the file, save updates and generate unique card IDs.

from flask import Flask, jsonify, request, render_template_string
from pathlib import Path
import json

app = Flask(__name__)
DATA_FILE = Path("cards.json")
COLUMNS = ["To Do", "In Progress", "Done"]

def load_cards():
    if not DATA_FILE.exists():
        return []
    return json.loads(DATA_FILE.read_text())

def save_cards(cards):
    DATA_FILE.write_text(json.dumps(cards, indent=2))

@app.get("/api/cards")
def get_cards():
    return jsonify(load_cards())

@app.post("/api/cards")
def add_card():
    data = request.get_json()
    title = data.get("title", "").strip()

    if not title:
        return {"error": "A title is required"}, 400

    cards = load_cards()
    next_id = max([card["id"] for card in cards], default=0) + 1
    card = {
        "id": next_id,
        "title": title,
        "description": data.get("description", "").strip(),
        "owner": data.get("owner", "").strip(),
        "status": "To Do"
    }
    cards.append(card)
    save_cards(cards)
    return jsonify(card), 201

The API has two jobs: return all cards and create a new card. The browser will use JavaScript to call these routes. A POST request is used for new work because it changes the stored collection. Validating the title on the server is important; browser validation alone can be bypassed.

Add the route that updates a card when it is moved:

@app.patch("/api/cards/<int:card_id>")
def update_card(card_id):
    data = request.get_json()
    cards = load_cards()

    for card in cards:
        if card["id"] == card_id:
            new_status = data.get("status", card["status"])
            if new_status not in COLUMNS:
                return {"error": "Invalid status"}, 400

            card["status"] = new_status
            save_cards(cards)
            return jsonify(card)

    return {"error": "Card not found"}, 404

This endpoint accepts a status such as In Progress, checks that it is a permitted workflow state and writes the change to disk. The check prevents malformed requests from creating columns that the interface does not understand.

Build The Visual Board

Add a page route and place the following HTML template below the API functions in app.py. The template uses CSS Grid to create a responsive board. Cards are marked as draggable, while each column is a drop target.

PAGE = """
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Python Kanban Board</title>
  <style>
    body { font-family: Arial, sans-serif; margin: 2rem; background: #f3f5f7; }
    form { display: flex; gap: .5rem; margin-bottom: 1.5rem; }
    input, button { padding: .7rem; }
    button { cursor: pointer; }
    .board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
    .column { background: #e2e8ee; padding: 1rem; min-height: 22rem; }
    .column h2 { font-size: 1.1rem; }
    .card { background: white; padding: .8rem; margin: .7rem 0;
            border-left: 4px solid #1769aa; cursor: grab; box-shadow: 0 1px 3px #bbb; }
    .owner { color: #58636e; font-size: .9rem; }
    @media (max-width: 700px) { .board { grid-template-columns: 1fr; } form { flex-wrap: wrap; } }
  </style>
</head>
<body>
  <h1>Project Kanban</h1>
  <form id="card-form">
    <input id="title" placeholder="Work item title" required>
    <input id="owner" placeholder="Owner">
    <button type="submit">Add card</button>
  </form>
  <main class="board" id="board"></main>

  <script>
    const columns = ["To Do", "In Progress", "Done"];

    async function loadBoard() {
      const response = await fetch("/api/cards");
      const cards = await response.json();
      const board = document.querySelector("#board");
      board.innerHTML = "";

      columns.forEach(status => {
        const column = document.createElement("section");
        column.className = "column";
        column.dataset.status = status;
        column.innerHTML = `<h2>${status}</h2>`;
        column.addEventListener("dragover", event => event.preventDefault());
        column.addEventListener("drop", moveCard);
        board.appendChild(column);

        cards.filter(card => card.status === status).forEach(card => {
          const item = document.createElement("article");
          item.className = "card";
          item.draggable = true;
          item.dataset.id = card.id;
          item.innerHTML = `<strong>${card.title}</strong>
            <div class="owner">${card.owner || "Unassigned"}</div>`;
          item.addEventListener("dragstart", event => {
            event.dataTransfer.setData("text/plain", card.id);
          });
          column.appendChild(item);
        });
      });
    }

    async function moveCard(event) {
      const cardId = event.dataTransfer.getData("text/plain");
      await fetch(`/api/cards/${cardId}`, {
        method: "PATCH",
        headers: {"Content-Type": "application/json"},
        body: JSON.stringify({status: event.currentTarget.dataset.status})
      });
      loadBoard();
    }

    document.querySelector("#card-form").addEventListener("submit", async event => {
      event.preventDefault();
      await fetch("/api/cards", {
        method: "POST",
        headers: {"Content-Type": "application/json"},
        body: JSON.stringify({
          title: document.querySelector("#title").value,
          owner: document.querySelector("#owner").value
        })
      });
      event.target.reset();
      loadBoard();
    });

    loadBoard();
  </script>
</body>
</html>
"""

@app.get("/")
def board():
    return render_template_string(PAGE)

if __name__ == "__main__":
    app.run(debug=True)

The browser calls loadBoard() when the page opens. It creates a column for each workflow state and places cards into the matching column. The dragstart event records the card ID, while the drop event sends the destination status to Flask.

Run the application with:

python app.py

Open the local address in a browser, add two or three cards, then drag them across the board. Refresh the page to confirm that the status has been saved in cards.json.

Improve Interaction And Data Quality

A useful visual management tool must make work understandable at a glance. The card title should describe an outcome rather than a vague activity. “Confirm supplier lead time” is more useful than “Supplier task”. The owner field should identify a person or team, and a short description can hold acceptance criteria or a link to supporting material.

You can extend the form with a description field and send it in the same JSON payload. Add this input inside the form:

<input id="description" placeholder="Short description">

Then add the value to the request body:

description: document.querySelector("#description").value

The Python API already stores the description, although the display template currently shows only the title and owner. You can expose it safely with:

item.innerHTML = `<strong>${card.title}</strong>
  <p>${card.description || "No description"}</p>
  <div class="owner">${card.owner || "Unassigned"}</div>`;

For a real deployment, escape user-generated text before inserting it with innerHTML, or build the elements with textContent. This prevents a user from inserting HTML or JavaScript into a card. You should also add authentication, audit logging and server-side permissions before the board is used for confidential project information.

A practical improvement is a work-in-progress limit. If the “In Progress” column should contain no more than four cards, the API can reject a fifth card or the interface can display a warning. That small constraint encourages the team to finish existing work before starting more tasks.

Test The Board With Local Team Conditions

A prototype should be tested against the way people actually work, rather than only against idealised sample data. A Sydney product team collaborating with colleagues in Melbourne may work across different schedules and daylight-saving changes. Store timestamps in UTC and display them in the user’s local zone when you add activity history.

Australian project teams also work across large distances. A supplier in regional New South Wales, a designer in Melbourne and a stakeholder in Brisbane may experience different internet quality and communication routines. Test the board on a mobile connection, keep the interface usable on a narrow screen and avoid relying on colour alone to communicate status.

Use the following checks before adding more features:

A further test is to create realistic work items from an Australian industry setting. For an automotive or advanced-manufacturing project, cards might cover a component trial, a quality review, a supplier quotation and a production-readiness decision. For a construction or infrastructure team, use items such as site access approval, survey review and materials delivery.

These examples help expose missing workflow states. If every card becomes blocked during procurement, a “Waiting” or “Blocked” state may be valuable. However, avoid creating a separate column for every exception. A small board is easier to read and makes bottlenecks more visible.

Add Measures Without Losing Simplicity

Once the board is being used consistently, collect a small amount of flow data. The most useful measures are cycle time, throughput and work-in-progress. Cycle time records how long a card takes from active work to completion. Throughput counts completed cards during a defined period. Work-in-progress shows how much unfinished work is competing for attention.

You can add created_at and completed_at fields using Python’s datetime module. When a card moves to “Done”, record the completion time. When it moves out of “Done”, clear that value. These timestamps allow a later report to calculate median delivery time rather than relying on impressions.

A board should support conversation, not replace it. During a daily or weekly review, discuss cards from right to left: what is ready to finish, what is blocked and what should start next. This is particularly useful for distributed teams where a shared visual workspace reduces the need for status meetings.

Useful extensions include:

If the prototype becomes part of a larger digital planning environment, define how its fields map to existing project or enterprise systems. Duplicate data entry quickly reduces trust. A lightweight integration that synchronises approved fields is usually more valuable than adding a large number of decorative features.

Operate The Prototype Responsibly

Before sharing the board beyond a local experiment, move configuration into environment variables, turn off Flask debug mode and create regular backups. A JSON file can be lost or overwritten, and it does not handle simultaneous edits reliably. SQLite is a sensible next step for a small internal tool; a managed database is more appropriate for multiple teams or high availability.

Think carefully about information governance in the Australian market. Project cards may contain customer details, commercial terms or employee information, so limit access and retain only what the team needs. If the board is hosted externally, check the organisation’s privacy, security and data-residency requirements before importing operational records.

Keep the interface visually calm. Consistent column names, clear card titles and a visible owner field are more valuable than complex animations. A board should make the next decision easier: start work, finish work, escalate a blockage or change the workflow.

For questions about the wider research context, digital visual planning and related collaborations, the Project Visit contacts page provides an appropriate point of reference. The implementation itself can remain small while the team learns which workflow information genuinely supports better decisions.

The practical takeaway is to begin with three honest columns, a few real work items and a reliable move-and-save cycle; once that foundation works, add measures and integrations only where they improve the flow of work.