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.