ToDo List App in Inspira using Htmx and Bootstrap

In this step-by-step guide, you’re going to create a web app using Inspira, Htmx and Bootstrap.

Getting Started

Let's get started! Follow these steps to install Inspira and set up the project directories:

$ mkdir inspira-todo-lists
$ cd inspira-todo-lists
$ python -m venv .venv
$ source .venv/bin/activate
$ pip install inspira
$ inspira init
$ inspira new database --name todo_db --type sqlite

File Generation

Execute the following commands to generate controller, repository, model, and service files:

$ inspira new controller todo
$ inspira new repository todo
$ inspira new service todo
$ inspira new model todo

Model

Open the todo.py model file and make the following changes

from sqlalchemy import Column, Integer, Boolean, DateTime, func, String
from database import Base


class Todo(Base):
    __tablename__ = "todos"
    id = Column(Integer, primary_key=True)
    title = Column(String(255))
    completed = Column(Boolean, default=False)
    created_at = Column(DateTime(timezone=True), server_default=func.now())

Repository

Open the todo_repository.py file and make the following changes:

from inspira.logging import log
from sqlalchemy.exc import SQLAlchemyError

from database import db_session
from src.model.todo import Todo


class TodoRepository:

    def get_all_todos(self):
        return db_session.query(Todo).all()

    def get_todo_by_id(self, id: int):
        return db_session.query(Todo).filter_by(id=id).first()

    def delete_todo_by_id(self, id: int):
        try:
            todo = self.get_todo_by_id(id)
            db_session.delete(todo)
            db_session.commit()
            return True
        except SQLAlchemyError as e:
            db_session.rollback()
            log.error(f"Error deleting todo: {e}")
            return False

    def mark_complete(self, id: int):
        try:
            todo = self.get_todo_by_id(id)
            todo.completed = True

            db_session.commit()
            return True
        except SQLAlchemyError as e:
            db_session.rollback()
            log.error(f"Error marking todo completed: {e}")
            return False

    def create_todo(self, todo: Todo):
        try:
            db_session.add(todo)
            db_session.commit()
            return True
        except SQLAlchemyError as e:
            db_session.rollback()
            log.error(f"Error creating todo: {e}")
            return False

get_all_todos(self): Fetches all Todo items from the database.

get_todo_by_id(self, id: int): Retrieves a specific Todo item from the database based on the provided id.

delete_todo_by_id(self, id: int): Attempts to delete a Todo item with the given id from the database.

mark_complete(self, id: int): Marks a Todo item with the specified id as completed.

create_todo(self, todo: Todo): Creates a new Todo item in the database.

This class encapsulates the database operations related to Todo items, providing a convenient and organized way to interact with the underlying data store.

Service

Afterward, open the todo_service.py file and add the following:

from src.model.todo import Todo
from src.repository.todo_repository import TodoRepository


class TodoService:
    def __init__(self, todo_repository: TodoRepository):
        self._todo_repository = todo_repository

    def get_all_todos(self):
        return self._todo_repository.get_all_todos()

    def create_todo(self, title: str):
        todo = Todo(title=title)

        return self._todo_repository.create_todo(todo)

    def mark_complete(self, id: int) -> bool:
        return self._todo_repository.mark_complete(id)

    def delete_todo_by_id(self, id: int) -> bool:
        return self._todo_repository.delete_todo_by_id(id)

The TodoService class provides a high-level interface for managing Todo-related operations.
It utilizes TodoRepository instance to interact with the underlying database. The class includes the following methods:

get_all_todos(self): Retrieves all Todo items using the associated TodoRepository instance.

create_todo(self, title: str): Creates a new Todo item with the specified title and delegates the storage operation to the TodoRepository.

mark_complete(self, id: int) -> bool: Marks a Todo item with the given ID as complete, leveraging the corresponding method in the TodoRepository.
Returns a boolean indicating the success of the operation.

delete_todo_by_id(self, id: int) -> bool: Attempts to delete a Todo item with the provided ID using the TodoRepository.
Returns a boolean indicating the success of the deletion operation.

In essence, the TodoService acts as an intermediary between the application logic and the database, offering a simplified and coherent interface for handling Todo-related functionalities.

Controller

To proceed, open the todo_controller.py file and insert the following:

from inspira.decorators.http_methods import get, post, delete, put
from inspira.decorators.path import path
from inspira.responses import TemplateResponse
from inspira.requests import Request

from src.service.todo_service import TodoService


@path("/todos")
class TodoController:

    def __init__(self, todo_service: TodoService):
        self._todo_service = todo_service

    @get()
    async def index(self, request: Request):
        todos = self._todo_service.get_all_todos()

        context = {
            "todos": todos,
        }

        return TemplateResponse("index.html", context)

    @post("/create")
    async def create_todo(self, request: Request):
        body = await request.form()
        title = body['title']
        self._todo_service.create_todo(title)

        todos = self._todo_service.get_all_todos()

        context = {
            "todos": todos
        }
        return TemplateResponse("todo-list.html", context)


    @delete("/{id}")
    async def delete_todo(self, request: Request, id: int):
        self._todo_service.delete_todo_by_id(id)
        todos = self._todo_service.get_all_todos()

        context = {
            "todos": todos
        }
        return TemplateResponse("todo-list.html", context)

    @put("/{id}")
    async def mark_complete(self, request: Request, id: int):
        self._todo_service.mark_complete(id)
        todos = self._todo_service.get_all_todos()

        context = {
            "todos": todos
        }
        return TemplateResponse("todo-list.html", context)

The TodoController class is a web controller responsible for handling HTTP requests related to Todo operations.
It is decorated with the @path("/todos") attribute, indicating the base path for its endpoints.
The controller takes a TodoService instance during initialization.

index(self, request: Request): Handles HTTP GET requests to retrieve all Todo items. It calls the get_all_todos method of the associated TodoService, renders the data into an HTML template, and returns a TemplateResponse with the context.

create_todo(self, request: Request): Manages HTTP POST requests for creating a new Todo item. It extracts the title from the request, delegates the creation to the create_todo method of the TodoService, and then refreshes the Todo list by rendering a template with the updated data.

delete_todo(self, request: Request, id: int): Processes HTTP DELETE requests to delete a Todo item by its ID. It invokes the delete_todo_by_id method of the TodoService and updates the Todo list for display.

mark_complete(self, request: Request, id: int): Handles HTTP PUT requests to mark a Todo item with the given ID as complete. It calls the mark_complete method of the TodoService and refreshes the Todo list for presentation.

In summary, the TodoController orchestrates the interaction between the web interface and the application logic provided by the TodoService, ensuring seamless handling of Todo-related actions through specified HTTP methods.

Creating the Templates Folder

In the project's main directory, create a folder named templates alongside the main.py file. Create the files, base.html, index.html and todo-list.html, within the newly created folder using the commands below:

$ mkdir templates
$ touch templates/base.html
$ touch templates/index.html
$ touch templates/todo-list.html

Now, insert the provided content into the base.html file:

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{% block title %}{% endblock %}</title>

    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"
          integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">

</head>
<body>

<div class="container mt-5">
    {% block content %}
    {% endblock %}
</div>


<!-- Bootstrap 5 js -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz"
        crossorigin="anonymous"></script>

<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.2"
        integrity="sha384-L6OqL9pRWyyFU3+/bjdSri+iIphTN/bvYyM37tICVyOJkWZLpP2vGn6VUEXgzg6h"
        crossorigin="anonymous"></script>
</body>
</html>

Now add the following to index.html:

{% extends 'base.html' %}
{% block title %} Todos {% endblock %}
{% block content %}

<h3 class="my-5">Welcome to Inspira Todo List App</h3>
<form hx-post="/todos/create" hx-target="#todoList" class="mx-auto">
    <div class="mb-3 row align-items-center">
        <label for="todoText" class="col-auto col-form-label">Enter your todo here: </label>
        <div class="col-6">
            <input type="text" name="title" class="form-control" id="todoText" required>
        </div>
        <div class="col-auto">
            <button class="btn btn-success">Add</button>
        </div>
    </div>
</form>
<div id="todoList">
    {% include 'todo-list.html' %}
</div>

{% endblock %}

Now open todo-list.html and add the following:

{% if todos %}

<div class="card">
    <ul class="list-group list-group-flush">
        {% for todo in todos %}
        <li class="list-group-item d-flex justify-content-between align-items-center">
            <div class="{% if todo.completed %} text-success text-decoration-line-through {% endif %}">
                {{ todo.title }}
            </div>
            <div>
                <span class="action badge rounded-pill text-bg-warning" hx-put="/todos/{{todo.id}}"
                      hx-target="#todoList" style="cursor: pointer;">✔</span>
                <span class="action badge rounded-pill text-bg-danger" hx-delete="/todos/{{todo.id}}"
                      hx-target="#todoList" hx-confirm="Are you sure you want to delete?"
                      style="cursor: pointer;">X</span>
            </div>
        </li>
        {% endfor %}
    </ul>
</div>

{% else %}

<h5>No todos</h5>

{% endif %}

Migrations

Run the following command to create a new migration file

$ inspira new migration create_table_todos

Open the migration file and add the following:

CREATE TABLE todos (
    id INTEGER PRIMARY KEY,
    title VARCHAR(255),
    completed BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

Now run the following command to apply the migration:

$ inspira migrate

Launching the Server

Initiate the server with the following command:

$ uvicorn main:app --reload

Testing the Service

Visit http://localhost:8000/todos in your browser to interact with the form. Enter the required details and submit the form for results.

Inspira ToDo app

Submit a title to see the results:

Inspira ToDos

Summary

Congratulations! You just used Inspira, Htmx and Bootstrap to create a ToDo list App.

The code is available on GitHub
Happy coding!