As developers, if we're honest, we spend a lot of time on CRUD (i.e. Create, Read, Update and Delete). The specifics may vary, but in most applications, CRUD constitutes a large part of the work.

Setting up Django templates to list objects, show their details, and create new instances can be time consuming. In many projects, especially internal tools, we would rather spend our time elsewhere.

The Neapolitan library aims to make it quick and easy to set up CRUD views.

In the words of its author:

If youโ€™ve ever looked longingly at Djangoโ€™s admin functionality and wished you could have the same kind of CRUD access that it provides for your frontend, then Neapolitan is for you.

Carlton Gibson

In this guide, we'll build a fictional ice cream shop ordering site. In our first iteration, we'll use the built-in templates provided by Neapolitan. Then we'll go back and progressively replace the standard templates with our own customized templates.

Previewing the project

Here's a video of the ice cream shop (with the final templates) in action:

Previewing the project

Want to see a live version of the app? You can view all the code for this project and try the running app here.

View the IceCreamDream project on Circumeo.

Installing the Requirements

Create the requirements.txt file if it does not already exist.


Django==5.0.2
psycopg2==2.9.9
tzdata==2023.4
neapolitan==24.4

Run the following command to install the project requirements.


pip install -r requirements.txt

Setting up the Django app

Install packages and create the Django application.


django-admin startproject icecreamshop .
python3 manage.py startapp core

Add neapolitan and core to the INSTALLED_APPS list.


# settings.py
INSTALLED_APPS = [
   ...,
  "neapolitan",
  "core",
]

Adding the database models

Overwrite the existing models.py with the following:


from django.db import models


class ContainerType(models.TextChoices):
    CONE = "cone", "Cone"
    CUP = "cup", "Cup"


class Flavor(models.TextChoices):
    VANILLA = "vanilla", "Vanilla"
    CHOCOLATE = "chocolate", "Chocolate"
    STRAWBERRY = "strawberry", "Strawberry"


class Topping(models.Model):
    name = models.CharField(max_length=100)
    price = models.DecimalField(max_digits=6, decimal_places=2)
    image_url = models.URLField(max_length=200)


class Order(models.Model):
    STATUS_CHOICES = (
        ("pending", "Pending"),
        ("completed", "Completed"),
        ("cancelled", "Cancelled"),
    )

    container = models.CharField(
        max_length=10, choices=ContainerType.choices, default=ContainerType.CONE
    )

    flavor = models.CharField(
        max_length=20, choices=Flavor.choices, default=Flavor.VANILLA
    )

    toppings = models.ManyToManyField("Topping", related_name="orders")
    special_requests = models.TextField(blank=True, null=True)

    total_price = models.DecimalField(max_digits=6, decimal_places=2)
    status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="pending")
    created_at = models.DateTimeField(auto_now_add=True)

Create and run the migrations.


python3 manage.py makemigrations
python3 manage.py migrate

Adding the forms

Open forms.py under core and add the following.


from django import forms
from core.models import Order, Topping

class OrderForm(forms.ModelForm):
    class Meta:
        model = Order
        fields = ["container", "flavor", "toppings", "special_requests"]
        widgets = {
            "container": forms.Select(attrs={"class": "border rounded px-4 py-2 bg-gray-100 text-gray-800 w-64 focus:outline-none focus:border-blue-500 hover:bg-gray-200 mb-4"}),
            "flavor": forms.Select(attrs={"class": "border rounded px-4 py-2 bg-gray-100 text-gray-800 w-64 focus:outline-none focus:border-blue-500 hover:bg-gray-200 mb-4"}),
            "toppings": forms.CheckboxSelectMultiple(attrs={"class": "grid grid-cols-3 gap-4 mb-4"}),
            "special_requests": forms.Textarea(attrs={"class": "border rounded px-4 py-2 bg-gray-100 text-gray-800 w-64 focus:outline-none focus:border-blue-500 hover:bg-gray-200 mb-4"}),
        }

Adding the views

Open views.py and replace the existing content with the following.


from django.http import HttpResponseRedirect
from django.shortcuts import render

from neapolitan.views import CRUDView
from core.models import Order, Topping
from core.forms import OrderForm

class OrderView(CRUDView):
    # The model is the only attribute that Neapolitan requires.
    model = Order

    # The form_class isn't required, but we define our own custom `OrderForm` because we
    # want more control over the widget styling.
    form_class = OrderForm

    # Controls which attributes of the model are shown in CRUD views.
    fields = ["container", "flavor", "toppings", "total_price", "special_requests"]

    def form_valid(self, form):
        """
        Overriding the `form_valid` method in order to calculate the total_price.
        """
        self.object = form.save(commit=False)

        self.object.total_price = 0
        for topping in form.cleaned_data["toppings"]:
            self.object.total_price += topping.price

        self.object.save()
        form.save_m2m()
        return HttpResponseRedirect(self.get_success_url())

    def get_context_data(self, **kwargs):
        """
        Overriding get_context_data to pass the topping options into the template.
        """
        return {**super().get_context_data(**kwargs), "toppings": Topping.objects.all()}

The CRUDView class is the heart of the action.

In a simpler view, the OrderView would only need to define the model attribute.

The OrderView overrides the form_valid method to calculate the total price. The get_context_data method is also overriden in order to extend the default context provided to the templates, so that we have access to the Topping instances.

Adding the base template

The base template is the only template we have to define when using Neapolitan. The rest of the CRUD templates are provided by Neapolitan.


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}Ice Cream Dream{% endblock %}</title>
    <script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
    <script src="https://cdn.tailwindcss.com"></script>
    {% block head %}{% endblock %}
  </head>
  <body class="flex items-center justify-center h-screen">
    <div class="max-w-3xl w-full">
      {% block content %}{% endblock %}
    </div>
  </body>
</html>

Updating the URLs

Create the urls.py file under core if it doesn't already exist.


from django.urls import path
from django.shortcuts import redirect
from core.views import OrderView

urlpatterns = OrderView.get_urls()

Updating the Templates

We haven't added any templates beside base.html but we have a functional app already.

Neapolitan allows for easy overriding of the built-in templates.

Customizing the Order Form

To replace just the order form, we can create a file named order_form.html to provide a customized template. As long as the template filename matches the model_form.html scheme, Neapolitan will use that template instead of the built-in template.


{% extends "core/base.html" %}
{% block content %}
    <form method="post">
        {% csrf_token %}
        {{ form.errors }}
        <div class="flex flex-col items-center justify-center min-h-screen bg-gradient-to-br p-10 from-pink-100 via-white to-brown-100">
            <h1 class="text-6xl font-bold text-pink-600 mb-10 text-center">Craft Your Ultimate Ice Cream Experience</h1>
            <div class="bg-white rounded-lg shadow-2xl p-10 max-w-4xl w-full">
                <div class="mb-8">
                    <label class="block text-gray-700 font-bold mb-2" for="base-flavor">Select Your Dream Base</label>
                    <div class="relative">
                        <select class="block appearance-none w-full bg-white border-2 border-pink-400 hover:border-pink-500 px-6 py-4 pr-8 rounded-full shadow-md text-xl leading-tight focus:outline-none focus:shadow-outline"
                                name="flavor"
                                id="base-flavor">
                            <option value="">Pick a flavor</option>
                            <option value="vanilla">Vanilla Bean Bliss ๐Ÿฆ</option>
                            <option value="chocolate">Decadent Chocolate Delight ๐Ÿซ</option>
                            <option value="strawberry">Strawberry Fields Forever ๐Ÿ“</option>
                        </select>
                        <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-4 text-gray-700">
                            <svg class="fill-current h-6 w-6"
                                 viewbox="0 0 20 20"
                                 xmlns="http://www.w3.org/2000/svg">
                                <path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"></path>
                            </svg>
                        </div>
                    </div>
                </div>
                <div class="mb-8">
                    <label class="block text-gray-700 font-bold mb-2" for="toppings">Top It Off</label>
                    <div class="grid grid-cols-3 mt-6 gap-6">
                        {% for topping in toppings %}
                            <label class="flex flex-col items-center cursor-pointer">
                                <div class="relative">
                                    <img class="topping-type order-option topping-option w-full h-40 object-cover rounded-2xl mb-2 transform hover:scale-105 transition-transform duration-300"
                                         src="{{ topping.image_url }}" />
                                    <div class="absolute top-0 right-0 bg-pink-500 text-white rounded-full w-8 h-8 flex items-center justify-center text-lg font-bold">
                                        +
                                    </div>
                                </div>
                                <span class="text-gray-700 text-center">{{ topping.name }}</span>
                                <input name="toppings"
                                       class="hidden"
                                       value="{{ topping.pk }}"
                                       type="checkbox" />
                            </label>
                        {% endfor %}
                    </div>
                </div>
                <div class="mb-8">
                    <label class="block text-gray-700 font-bold mb-2" for="sauce">Drizzle Some Magic</label>
                    <div class="flex justify-around">
                        <label class="flex items-center">
                            <input class="form-radio h-6 w-6 text-pink-600 transition-colors duration-300"
                                   id="chocolate-sauce"
                                   name="sauce"
                                   type="radio"
                                   value="chocolate" />
                            <span class="ml-2 text-gray-700 text-xl">Chocolate Waterfall ๐Ÿซ</span>
                        </label>
                        <label class="flex items-center">
                            <input class="form-radio h-6 w-6 text-pink-600 transition-colors duration-300"
                                   id="caramel-sauce"
                                   name="sauce"
                                   type="radio"
                                   value="caramel" />
                            <span class="ml-2 text-gray-700 text-xl">Caramel Cascade ๐Ÿฎ</span>
                        </label>
                        <label class="flex items-center">
                            <input class="form-radio h-6 w-6 text-pink-600 transition-colors duration-300"
                                   id="strawberry-sauce"
                                   name="sauce"
                                   type="radio"
                                   value="strawberry" />
                            <span class="ml-2 text-gray-700 text-xl">Strawberry Stream ๐Ÿ“</span>
                        </label>
                    </div>
                </div>
                <div class="mb-8">
                    <label class="block text-gray-700 font-bold mb-2" for="container">Choose Your Vessel</label>
                    <div class="flex justify-center space-x-8">
                        <label class="flex flex-col items-center cursor-pointer">
                            <img alt="A crisp, crunchy waffle cone, golden brown and ready to cradle your ice cream creation with its delightful texture and subtle sweetness."
                                 class="container-type order-option w-24 h-24 object-cover rounded-full mb-2 transform hover:scale-110 transition-transform duration-300"
                                 src="https://c.pxhere.com/photos/86/1b/ice_cream_cone_chocolate_sprinkles_waffle_cold_tasty_frozen_delicious_scoop-1084254.jpg!d" />
                            <span class="text-gray-700 text-xl text-center">Waffle Cone Wonder</span>
                            <input class="hidden" id="cone" name="container" type="radio" value="cone" />
                        </label>
                        <label class="flex flex-col items-center cursor-pointer">
                            <img alt="A classic, simple cup, ready to be filled to the brim with your favorite ice cream flavors and toppings for a no-fuss, pure ice cream experience."
                                 class="container-type order-option w-24 h-24 object-cover rounded-full mb-2 transform hover:scale-110 transition-transform duration-300"
                                 src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEheIGqhGBTsqEk-eLSAuv4d-KKBI6rnUE3PgmASXdnTprg5ZZRXbwUUJj-t7cz5EWE-Es8jSNLOqPPNbbMJg3p-Lx_k1hLmVm7HJ-5EY_HBeZTVDQDBXd6GUxojaEULlILFsIn9sxnKB73unoWNJ8Yh_yvetHr8WBFdCxbV0CUTAsEN2LexqPKMqpEb0wQ/s3264/IMG20231126102511.jpg" />
                            <span class="text-gray-700 text-xl text-center">Cup of Contentment</span>
                            <input class="hidden" id="cup" name="container" type="radio" value="cup" />
                        </label>
                    </div>
                </div>
                <div class="mb-8">
                    <label class="block text-gray-700 font-bold mb-2" for="scoops">Scoop It Up</label>
                    <input class="w-full h-6 bg-pink-200 rounded-full appearance-none cursor-pointer transition-colors duration-300"
                           id="scoops"
                           name="scoops"
                           max="5"
                           min="1"
                           step="1"
                           type="range" />
                    <div class="flex justify-between text-gray-700 text-lg mt-2">
                        <span>1</span>
                        <span>2</span>
                        <span>3</span>
                        <span>4</span>
                        <span>5</span>
                    </div>
                </div>
                <div class="mb-8">
                    <label class="block text-gray-700 font-bold mb-2" for="extras">Finishing Touches</label>
                    <div class="flex justify-center space-x-8">
                        <label class="flex items-center cursor-pointer">
                            <input class="form-checkbox h-6 w-6 text-pink-600 rounded-full transition-colors duration-300"
                                   id="whipped-cream"
                                   type="checkbox" />
                            <span class="ml-2 text-gray-700 text-xl">Whipped Dream ๐Ÿฎ</span>
                        </label>
                        <label class="flex items-center cursor-pointer">
                            <input class="form-checkbox h-6 w-6 text-pink-600 rounded-full transition-colors duration-300"
                                   id="cherry"
                                   type="checkbox" />
                            <span class="ml-2 text-gray-700 text-xl">Cherry on Top ๐Ÿ’</span>
                        </label>
                    </div>
                </div>
                <div class="mb-8">
                    <label class="block text-gray-700 font-bold mb-2" for="notes">Special Requests</label>
                    <textarea class="w-full px-4 py-3 text-gray-700 border-2 border-pink-400 rounded-xl focus:outline-none focus:border-pink-600 transition-colors duration-300"
                              id="notes"
                              name="special_requests"
                              placeholder="Any extra wishes for your dream ice cream? Let us know!"
                              rows="4"></textarea>
                </div>
                <button type="submit"
                        class="w-full bg-gradient-to-r from-pink-500 to-purple-500 hover:from-purple-500 hover:to-pink-500 text-white font-bold py-4 px-8 rounded-full shadow-lg transform hover:scale-105 transition-all duration-300">
                    Bring My Ice Cream Dream to Life! ๐Ÿฆโœจ
                </button>
            </div>
        </div>
    </form>
    <style>
  @import url('https://fonts.googleapis.com/css2?family=Lobster&display=swap');

  body {
    font-family: 'Lobster', cursive;
  }

  .topping-option { width: 190px; }
  .option-selected { border: 3px solid #f00; }

  .topping-type.option-selected { border-radius: 1rem; }
  .container-type.option-selected { border-radius: 100%; }
    </style>
    <script>
$(document).ready(function() {
    $('.order-option').on('click', function(event) {
        if (event.target === this) {
            var wasSelected = $(this).hasClass('option-selected');
            $(this).toggleClass('option-selected');
            $(this).find('input[type="checkbox"]').prop('checked', !wasSelected);
        }
    });
});
    </script>
{% endblock %}

Customizing the Order Listing Page

We can also replace the order listing template.

Neapolitan follows a template naming convention based on the model class name. To replace the listing page, we should use the name order_list.html for the template.


{% extends "core/base.html" %}
{% block content %}
    <header class="bg-pink-100 py-4 shadow-md">
        <div class="container mx-auto flex items-center justify-between px-4">
            Ice Cream Dreams
            <nav>
                <ul class="flex space-x-4">
                    <li>
                        <a class="text-pink-600 font-bold" href="{% url 'order-list' %}">Your Orders</a>
                    </li>
                </ul>
            </nav>
        </div>
    </header>
    <main class="container mx-auto py-8 px-4">
        <div class="flex items-center justify-between mb-8">
            <h1 class="text-5xl font-bold text-pink-600 mb-0 text-center">Your Orders</h1>
            <a class="bg-pink-600 hover:bg-pink-700 text-white font-bold py-2 px-4 rounded-full transition-colors duration-300"
               href="{% url 'order-create' %}">New Order</a>
        </div>
        <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
            {% for order in order_list %}
                <div class="bg-white rounded-lg shadow-md overflow-hidden transform hover:scale-105 transition-transform duration-300">
                    <div class="p-4">
                        <div class="flex items-center justify-between mb-2">
                            <span class="text-sm font-bold text-gray-600">Order #{{ order.id }}</span>
                            <span class="text-sm text-gray-600">{{ order.created_at|date:"F j, Y" }}</span>
                        </div>
                        <div class="flex items-center mb-2">
                            <img alt="{{ order.flavor }}"
                                 class="w-12 h-12 object-cover rounded-full mr-2"
                                 height="48"
                                 src="{{ order.flavor.image.url }}"
                                 width="48" />
                            <div>
                                <h2 class="text-lg font-bold text-gray-800">{{ order.flavor.name }}</h2>
                                <p class="text-sm text-gray-600">
                                    {% for topping in order.toppings.all %}
                                        {{ topping.name }}
                                        {% if not forloop.last %},{% endif %}
                                    {% endfor %}
                                </p>
                            </div>
                        </div>
                        <div class="flex items-center justify-between">
                            <span class="text-lg font-bold text-pink-600">${{ order.total_price }}</span>
                            <span class="bg-{{ order.status_color }}-100 text-{{ order.status_color }}-800 text-sm font-medium px-2.5 py-0.5 rounded">{{ order.get_status_display }}</span>
                        </div>
                    </div>
                </div>
            {% empty %}
                <p class="text-gray-600">No orders found.</p>
            {% endfor %}
        </div>
    </main>
    <style>
  @import url('https://fonts.googleapis.com/css2?family=Lobster&display=swap');

  body {
    font-family: 'Lobster', cursive;
  }
    </style>
{% endblock %}

The orders are automatically available in the template as the order_list variable.

Up and Running Faster with Neapolitan

Hopefully this guide has shown how quickly you can be up and running with Neapolitan.

The built-in templates provide a quick starting point, while the ability to override specific CRUDView methods provides flexibility.