I’m working through Hypermedia Sytstems using django and htmx in public. This post covers building out the Web 1.0 edition of the book’s contacts app. For background on this series of posts, see the introduction. To follow along with the code, you can access the repository at sr.ht. If you’d like to follow along starting here, check out the “boilerplate” tag from git.

Chapter 3 of Hypermedia Systems lays out the requirements for a CRUD application that allows a user to view and search a set of contacts, add a new contact, see the details of a single contact, edit those details, and delete contacts.

Django makes short work of this.

To start, I add a Contact class to contacts/models.py:

from django.db import models


class Contact(models.Model):
    first_name = models.CharField(max_length=255)
    last_name = models.CharField(max_length=255)
    phone_number = models.CharField(max_length=32)
    email = models.EmailField()

    def __str__(self):
        return f"{self.first_name} {self.last_name} <{self.email}>"

To make it easy to bootstrap, I also add an admin class using the admin_generator command from django_extensions:

python ./manage.py admin_generator contacts >contacts/admin.py

Then I generate and run migrations, create a superuser, and start my development server:

python manage.py makemigrations
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver

Visiting http://localhost:8000/admin then lets me quickly add a few contacts to make it easier to build out my views.

django admin

With a model in place and the DB populated enough to build a view, I’ll start by creating a base template for my pages. Though I might want to make it look nicer later, I’m just going to drop a copy of mvp.css into my static files for now and use that to have some basic styling while I work.

# create a location for static resources at the root of my project
mkdir static
# save a copy of mvp.css there
curl -o static/mvp.css https://andybrewer.github.io/mvp/mvp.css

Then add that to config/settings.py

STATIC_URL = "static/"
STATICFILES_DIRS = [
    BASE_DIR / "static",
]

and create a templates/base.html to use it:

{% load static %}
<!DOCTYPE html>
<html lang="en">

    <head>
        <link rel="stylesheet" href="{% static 'mvp.css' %}">

        <meta charset="utf-8">
        <meta name="description" content="Contacts management app for Hypertext Systems">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>{% block title %}Contacts.app{% endblock %}</title>
    </head>
    <body>
        <header>
            <nav>
                <h1><a href="{% url 'contacts:index' %}">Contacts.app</a></h1>
            </nav>
        </header>
        <main>
            {% block content %}
                <h1>Content goes here!</h1>
            {% endblock content %}
        </main>
    </body>
</html>

Then I create a template to render a basic contact list in templates/contacts/contact_list.html with placeholder links for editing and viewing:

{% extends "base.html" %}
{% block content %}
    <table>
        <thead>
            <tr>
                <th>First Name</th>
                <th>Last Name</th>
                <th>Phone Number</th>
                <th>Email Address</th>
                <th></th>
            </tr>
        </thead>
        <tbody>
            {% for contact in contacts %}
                <tr>
                    <td>{{contact.first_name}}</td>
                    <td>{{contact.last_name}}</td>
                    <td>{{contact.phone_number}}</td>
                    <td>{{contact.email}}</td>
                    <td>
                        <a href="#">Edit</a>
                        <a href="#">View</a>
                    </td>
                </tr>
            {% endfor %}
        </tbody>
    </table>
{% endblock content %}

And a view function to render it with a list of all contacts in contacts/views.py:

def contacts(request):
    contacts_list = Contact.objects.all()
    return render(request, "contacts/contact_list.html", {"contacts": contacts_list})

I add that to urlpatterns in contacts/urls.py:

urlpatterns = [
    path("", views.index, name="index"),
    path("contacts", views.contacts, name="contacts"),
]

and visit http://localhost:8000/contacts to make sure it’s working.

First rendering of contacts list

At this point, it feels like a good time to commit my changes.

For convenience, I’m going to make the index view redirect to the contact list by chaging the index() function:

from django.shortcuts import render, redirect

from .models import Contact


def index(request):
    return redirect('contacts:contacts')


Then it’s time to add the search form. First, I create contacts/forms.py with a simple search form class:

from django import forms


class SearchForm(forms.Form):
    query = forms.CharField(label="Search", max_length=255)

and I add it to the view context for the contacts list:

def contacts(request):
    contacts_list = Contact.objects.all()
    search_form = SearchForm()
    ctx = {
        "contacts": contacts_list,
        "search_form": search_form,
    }
    return render(request, "contacts/contact_list.html", ctx)

and make the template render the form:

{% extends "base.html" %}
{% block content %}
    <form method="get" action="{% url 'contacts:contacts' %}">
        {{ search_form.as_div }}
        <input type="submit" value="Search">
    </form>
    <table>
        <thead>
            <tr>
                <th>First Name</th>
                <th>Last Name</th>
                <th>Phone Number</th>
                <th>Email Address</th>
                <th></th>
            </tr>
        </thead>
        <tbody>
            {% for contact in contacts %}
                <tr>
                    <td>{{contact.first_name}}</td>
                    <td>{{contact.last_name}}</td>
                    <td>{{contact.phone_number}}</td>
                    <td>{{contact.email}}</td>
                    <td>
                        <a href="#">Edit</a>
                        <a href="#">View</a>
                    </td>
                </tr>
            {% endfor %}
        </tbody>
    </table>
{% endblock content %}

After I confirm that the form renders and that clicking the search button submits the query to the server, it’s time to make the view handle the form:

def contacts(request):
    contacts_list = Contact.objects.all()
    search_form = SearchForm()
    query = request.GET.get("query", None)
    if query:
        search_form = SearchForm(initial={"query": query})
        contacts_list = contacts_list.filter(
            Q(first_name__icontains=query)
            | Q(last_name__icontains=query)
            | Q(email__icontains=query)
        )
    ctx = {
        "contacts": contacts_list,
        "search_form": search_form,
    }
    return render(request, "contacts/contact_list.html", ctx)

Once search is working, it’s time to introduce the ability to create contacts without using django admin.

First, a minimal form in contacts/forms.py:

class ContactForm(forms.ModelForm):
    class Meta:
        model = Contact
        fields = ["first_name", "last_name", "phone_number", "email"]

Then a template to display it and enable users to submit POST requests:

{% extends 'base.html' %}
{% block content %}
    <div>
        <form method="post" action="{% url 'contacts:add_contact' %}">
            {% csrf_token %}
            {{ form.as_div }}
            <input type="submit" value="Save">
        </form>
    </div>
{% endblock content %}

Now a view function to handle requests and display the form:

def add_contact(request):
    form = ContactForm()
    ctx = {"form": form}
    return render(request, "contacts/contact_add.html", ctx)

Then it gets added to URL patterns:

urlpatterns = [
    path("", views.index, name="index"),
    path("contacts", views.contacts, name="contacts"),
    path("contacts/new", views.add_contact, name="add_contact")
]

And finally linked at the bottom of the contact list in templates/contacts/contact_list.html:

{% extends "base.html" %}
{% block content %}
   ...
    <table>
        ...
    </table>
    <div>
        <a href="{% url 'contacts:add_contact' %}">Add new contact</a>
    </div>
{% endblock content %}

Once everything is displaying and clickable, update the view function to handle POST and create a new contact:

def add_contact(request):
    form = ContactForm()
    if request.method == "POST":
        form = ContactForm(request.POST)
        if form.is_valid():
            contact = form.save(commit=True)
            messages.info(request, f"Contact {contact} created successfully")
            return redirect("contacts:contacts")
    ctx = {"form": form}
    return render(request, "contacts/contact_add.html", ctx)

And add a list of messages to the header in templates/base.html to make it easier to see the results of the form:

{% load static %}
<!DOCTYPE html>
<html lang="en">

    <head>
        <link rel="stylesheet" href="{% static 'mvp.css' %}">

        <meta charset="utf-8">
        <meta name="description" content="Contacts management app for Hypertext Systems">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>{% block title %}Contacts.app{% endblock %}</title>
    </head>
    <body>
        <header>
            <nav>
                <h1><a href="{% url 'contacts:index' %}">Contacts.app</a></h1>
            </nav>
            {% if messages %}
            <ul>
                {% for message in messages %}
                    <li>{{ message }}</li>
                {% endfor %}
            </ul>
            {% endif %}
        </header>
        <main>
            {% block content %}
                <h1>Content goes here!</h1>
            {% endblock content %}
        </main>
    </body>
</html>

Now when the new contact form is filled out, we see results:

contact added

It’s once again time to commit progress to git.

Even though it does not add much new functionality at this point, it’s time to add the ability to view details for a single contact.

First, a new view function:

def contact_detail(request, pk):
    contact = get_object_or_404(Contact, pk=pk)
    return render(request, "contacts/contact_detail.html", {"contact": contact})

Then a new template at contacts/contact_detail.html:

{% extends 'base.html' %}
{% block content %}
    <h1>{{ contact.first_name }} {{ contact.last_name }}</h1>
    <div>
    <div>Phone: {{ contact.phone_number }}</div>
    <div>Email: {{ contact.email }}</div>
    </div>

    <div>
    <a href="#">Edit</a>
    <a href="{% url 'contacts:contacts' %}">Back</a>
    </div>
{% endblock content %}

Then we need to add the URL to the patterns:

urlpatterns = [
    path("", views.index, name="index"),
    path("contacts", views.contacts, name="contacts"),
    path("contacts/new", views.add_contact, name="add_contact"),
    path("contacts/<int:pk>", views.contact_detail, name="contact_detail"),
]

And fix the link in the list template (templates/contact_list.html)

{% extends "base.html" %}
{% block content %}
...
            {% for contact in contacts %}
                <tr>
                    <td>{{contact.first_name}}</td>
                    <td>{{contact.last_name}}</td>
                    <td>{{contact.phone_number}}</td>
                    <td>{{contact.email}}</td>
                    <td>
                        <a href="#">Edit</a>
                        <a href="{% url 'contacts:contact_detail' contact.id %}">View</a>
                    </td>
                </tr>
            {% endfor %}
...
{% endblock content %}

Once the view link is working, it’s time to make the edit links work.

It’s very much the same drill as adding a new contact, since we can even re-use the model form.

First, a skeletal view function:

def edit_contact(request, pk):
    contact = get_object_or_404(Contact, pk=pk)
    form = ContactForm(instance=contact)
    ctx = {"form": form, "contact": contact}
    return render(request, "contacts/contact_edit.html", ctx)

Then a new template at contacts/contact_edit.html which looks very much like the add form:

{% extends 'base.html' %}
{% block content %}
    <div>
        <form method="post" action="{% url 'contacts:edit_contact' contact.id %}">
            {% csrf_token %}
            {{ form.as_div }}
            <input type="submit" value="Save">
        </form>
    </div>
{% endblock content %}

Then an update to contacts/urls.py:

urlpatterns = [
    path("", views.index, name="index"),
    path("contacts", views.contacts, name="contacts"),
    path("contacts/new", views.add_contact, name="add_contact"),
    path("contacts/<int:pk>", views.contact_detail, name="contact_detail"),
    path("contacts/<int:pk>/edit", views.edit_contact, name="edit_contact"),
]

And one to templates/contact_list.html to fix the edit link:

...
                    <td>
                        <a href="{% url 'contacts:edit_contact' contact.id %}">Edit</a>
                        <a href="{% url 'contacts:contact_detail' contact.id %}">View</a>
                    </td>
...

Along with the same thing on the detail page:

{% extends 'base.html' %}
{% block content %}
    <h1>{{ contact.first_name }} {{ contact.last_name }}</h1>
    <div>
    <div>Phone: {{ contact.phone_number }}</div>
    <div>Email: {{ contact.email }}</div>
    </div>

    <div>
    <a href="{% url 'contacts:edit_contact' contact.id %}">Edit</a>
    <a href="{% url 'contacts:contacts' %}">Back</a>
    </div>
{% endblock content %}

Now we can test in the browser to make sure the edit forms appear and are populated with contact information.

All that’s left to do is handle a POST in the edit view function:

def edit_contact(request, pk):
    contact = get_object_or_404(Contact, pk=pk)
    form = ContactForm(instance=contact)
    if request.method == "POST":
        form = ContactForm(request.POST, instance=contact)
        if form.is_valid():
            new_contact = form.save()
            messages.info(request, f"Contact id {pk} saved")
            return redirect(
                "contacts:contact_detail",
                contact.pk,
            )
    ctx = {"form": form, "contact": contact}
    return render(request, "contacts/contact_edit.html", ctx)

With editing working, all that’s left is deleting contacts. For now, we’ll just add that to the edit template (templates/contacts/contact_edit.html):

{% extends 'base.html' %}
{% block content %}
    <div>
        <form method="post" action="{% url 'contacts:edit_contact' contact.id %}">
            {% csrf_token %}
            {{ form.as_div }}
            <input type="submit" value="Save">
        </form>
        <div>
            <form method="post" action="{% url 'contacts:delete_contact' contact.id %}">
                {% csrf_token %}
                <button type="submit">Delete Contact</button>
            </form>
            <div>
                <a href="{% url 'contacts:contacts' %}">Back</a>
            </div>
        </div>
    </div>
{% endblock content %}

Add a corresponding view:

@require_POST
def delete_contact(request, pk):
    contact = get_object_or_404(Contact, pk=pk)
    contact.delete()
    messages.info(request, f"Contact {contact} deleted.")
    return redirect("contacts:contacts")

Along with a URL mapping:

urlpatterns = [
    path("", views.index, name="index"),
    path("contacts", views.contacts, name="contacts"),
    path("contacts/new", views.add_contact, name="add_contact"),
    path("contacts/<int:pk>", views.contact_detail, name="contact_detail"),
    path("contacts/<int:pk>/edit", views.edit_contact, name="edit_contact"),
    path("contacts/<int:pk>/delete", views.delete_contact, name="delete_contact"),
]

Once deletion works, everything from the “Web 1.0” version of contacts.app described in chapter 3 is in place. The next post will pick up with Chapter 4.

Minor Update

I got tired of some of the weird spacing I was seeing in Firefox from mvp.css and decided to try a few similar styles. Switching to bolt.css made this starting point feel a little bit nicer.

using bolt.css instead of mvp.css

Obviously it doesn’t matter at all, but I like clicking around better with this style.

Minor Update Number 2

I kept messing around with this for a few minutes more. I’m completely rudderless when it comes to these smmall, no- or minimal-class CSS frameworks because I generally just use Tailwind. But even though I want a little bit of styling, I don’t want to roll it myself and I don’t want a build step yet. These seem like a nice compromise.

Missing.css seems to be even closer to what I wanted, so I’ve also vendored that and have configured my Web 1.0 version to use it.

using missing.css

The next post looks at adding email validation with HTMX.