Project: A Small Online Store
Time to put the whole course together. In this project you will build Lucknow Chikan House, a small online shop for hand-embroidered kurtas and dupattas. Visitors browse products by category, add them to a cart, and place a cash-on-delivery order. Staff manage products, stock and orders in the admin.
You will use almost everything from the Django lessons: models and relationships, the admin, URLs and views, templates, a ModelForm, sessions, messages, transactions with F() expressions, and tests. The finished app is about 300 lines of Python and HTML, and every output below comes from running it.
What we are building
| Page | URL | What it does |
|---|---|---|
| Product list | / and /category/<slug>/ | All active products, or one category |
| Product page | /<slug>/ | Details and an "Add to cart" form |
| Cart | /cart/ | Lines, totals, remove buttons, checkout link |
| Checkout | /checkout/ | Name, phone and address form; places the order |
| Order done | /orders/<id>/done/ | Thank-you page with the order summary |
| Admin | /admin/ | Products, categories and orders for staff |
Step 1: Project and settings
Create a folder, a virtual environment and the project, as in Django Projects & Apps:
mkdir chikan-house
cd chikan-house
python -m venv .venv
source .venv/bin/activate # Windows PowerShell: .venv\Scripts\Activate.ps1
python -m pip install django
django-admin startproject config .
python manage.py startapp shopIn config/settings.py, add "shop" to INSTALLED_APPS, set "DIRS": [BASE_DIR / "templates"] in TEMPLATES, and set TIME_ZONE = "Asia/Kolkata" so order times show Indian time. The session and messages apps are already installed in a new project, and we need both.
Step 2: Models
Four models describe the shop. Note the design decisions in the comments.
# shop/models.py
from decimal import Decimal
from django.db import models
from django.urls import reverse
class Category(models.Model):
name = models.CharField(max_length=60, unique=True)
slug = models.SlugField(unique=True)
class Meta:
verbose_name_plural = "categories"
ordering = ["name"]
def __str__(self):
return self.name
class Product(models.Model):
category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name="products")
name = models.CharField(max_length=120)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
price = models.DecimalField(max_digits=8, decimal_places=2)
stock = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("shop:product", args=[self.slug])
class Order(models.Model):
class Status(models.TextChoices):
NEW = "new", "New"
PACKED = "packed", "Packed"
SHIPPED = "shipped", "Shipped"
name = models.CharField(max_length=100)
phone = models.CharField(max_length=10)
address = models.TextField()
status = models.CharField(max_length=10, choices=Status, default=Status.NEW)
created = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Order {self.pk} ({self.name})"
@property
def total(self):
return sum((item.line_total for item in self.items.all()), Decimal("0"))
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items")
product = models.ForeignKey(Product, on_delete=models.PROTECT)
price = models.DecimalField(max_digits=8, decimal_places=2) # price when ordered
quantity = models.PositiveIntegerField()
@property
def line_total(self):
return self.price * self.quantityThree choices matter for a real shop:
OrderItem.pricecopies the price at the moment of ordering. If the shop raises the kurta price next month, old orders must still show what the customer actually paid.PROTECTon products and categories stops staff from deleting a product that appears in past orders. Hide it withis_active = Falseinstead.CASCADEfrom order to items: deleting an order (rare, but possible for a test order) removes its lines too.
python manage.py makemigrations shop
python manage.py migrateMigrations for 'shop':
shop\migrations\0001_initial.py
+ Create model Category
+ Create model Order
+ Create model Product
+ Create model OrderItemStep 3: The admin
Staff need to add products, change prices and stock quickly, and see each order with its lines:
# shop/admin.py
from django.contrib import admin
from .models import Category, Order, OrderItem, Product
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
prepopulated_fields = {"slug": ["name"]}
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ["name", "category", "price", "stock", "is_active"]
list_editable = ["price", "stock", "is_active"]
list_filter = ["category", "is_active"]
search_fields = ["name"]
prepopulated_fields = {"slug": ["name"]}
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0
readonly_fields = ["product", "price", "quantity"]
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ["id", "name", "phone", "status", "created", "total"]
list_filter = ["status", "created"]
search_fields = ["name", "phone"]
inlines = [OrderItemInline]The order lines are read-only in the admin, so nobody changes what a customer ordered by accident; staff only move the status from New to Packed to Shipped. Run python manage.py createsuperuser, log in, and add two categories (Kurtas, Dupattas) and three products: a white chikan kurta (Rs 1,299, 5 in stock), a pastel blue kurta (Rs 1,499, out of stock) and a georgette dupatta (Rs 899, 8 in stock).
Step 4: A cart in the session
The cart does not need a database table. It lives in the visitor's session as a small dictionary of product IDs and quantities, so it works without an account:
# shop/cart.py
from decimal import Decimal
from .models import Product
SESSION_KEY = "cart"
class Cart:
"""A shopping cart stored in the session as {"product_id": quantity}."""
def __init__(self, request):
self.session = request.session
self.data = self.session.setdefault(SESSION_KEY, {})
def add(self, product, quantity=1):
key = str(product.pk) # session data is JSON: keys must be strings
self.data[key] = min(self.data.get(key, 0) + quantity, product.stock)
self.session.modified = True
def remove(self, product):
self.data.pop(str(product.pk), None)
self.session.modified = True
def clear(self):
self.session.pop(SESSION_KEY, None)
def lines(self):
products = Product.objects.filter(pk__in=self.data.keys())
for product in products:
quantity = self.data[str(product.pk)]
yield {"product": product, "quantity": quantity, "total": product.price * quantity}
def total(self):
return sum((line["total"] for line in self.lines()), Decimal("0"))
def __len__(self):
return sum(self.data.values())The session stores only IDs and quantities, never prices. Prices are read fresh from the database in lines(), so a visitor cannot change a price by tampering with anything, and price changes apply immediately. session.modified = True is needed because we change a dictionary inside the session, which Django cannot notice by itself. add() caps the quantity at the current stock.
Step 5: Views
# shop/views.py
from django.contrib import messages
from django.db import transaction
from django.db.models import F
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST
from .cart import Cart
from .forms import CheckoutForm
from .models import Category, Order, OrderItem, Product
def product_list(request, category_slug=None):
products = Product.objects.filter(is_active=True).select_related("category")
category = None
if category_slug:
category = get_object_or_404(Category, slug=category_slug)
products = products.filter(category=category)
return render(request, "shop/product_list.html", {
"products": products,
"category": category,
"categories": Category.objects.all(),
})
def product_detail(request, slug):
product = get_object_or_404(Product, slug=slug, is_active=True)
return render(request, "shop/product_detail.html", {"product": product})
@require_POST
def cart_add(request, slug):
product = get_object_or_404(Product, slug=slug, is_active=True)
quantity = request.POST.get("quantity", "1")
quantity = int(quantity) if quantity.isdigit() and int(quantity) > 0 else 1
if product.stock == 0:
messages.error(request, f"Sorry, {product.name} is out of stock.")
else:
Cart(request).add(product, quantity)
messages.success(request, f"Added {product.name} to your cart.")
return redirect("shop:cart")
@require_POST
def cart_remove(request, slug):
Cart(request).remove(get_object_or_404(Product, slug=slug))
return redirect("shop:cart")
def cart_detail(request):
return render(request, "shop/cart.html", {"cart": Cart(request)})Adding and removing are @require_POST views: they change data, so they must not be reachable by a plain link, and Django's CSRF protection then covers them. Each one redirects afterwards (Post/Redirect/Get) and leaves a message for the next page. quantity.isdigit() rejects anything that is not a positive whole number before int() could crash on it.
The checkout is the most important part. It must never sell stock that does not exist, even if two customers order the last kurta at the same second:
def checkout(request):
cart = Cart(request)
if len(cart) == 0:
return redirect("shop:list")
form = CheckoutForm(request.POST or None)
if request.method == "POST" and form.is_valid():
try:
order = place_order(form, cart)
except ValueError as error:
messages.error(request, str(error))
return redirect("shop:cart")
cart.clear()
return redirect("shop:done", pk=order.pk)
return render(request, "shop/checkout.html", {"form": form, "cart": cart})
@transaction.atomic
def place_order(form, cart):
order = form.save()
for line in cart.lines():
product, quantity = line["product"], line["quantity"]
updated = Product.objects.filter(pk=product.pk, stock__gte=quantity).update(
stock=F("stock") - quantity
)
if not updated:
raise ValueError(f"Only {product.stock} of {product.name} left. Please update your cart.")
OrderItem.objects.create(order=order, product=product, price=product.price, quantity=quantity)
return order
def order_done(request, pk):
order = get_object_or_404(Order.objects.prefetch_related("items__product"), pk=pk)
return render(request, "shop/done.html", {"order": order})Two techniques from QuerySets & Relationships make this safe:
filter(stock__gte=quantity).update(stock=F("stock") - quantity)checks and reduces stock in a single SQLUPDATE. If another order took the stock a moment earlier, the filter matches nothing,update()returns 0, and we stop.@transaction.atomicwraps the whole function in a database transaction. If any line fails, the exception undoes everything: the order row, earlier order items and earlier stock changes. A customer never gets half an order.
The checkout form is a small ModelForm with a phone check:
# shop/forms.py
from django import forms
from .models import Order
class CheckoutForm(forms.ModelForm):
class Meta:
model = Order
fields = ["name", "phone", "address"]
widgets = {"address": forms.Textarea(attrs={"rows": 3})}
def clean_phone(self):
phone = self.cleaned_data["phone"]
if not (phone.isdigit() and len(phone) == 10 and phone[0] in "6789"):
raise forms.ValidationError("Enter a 10-digit mobile number.")
return phonefields lists only what the customer may set. status is not in the list, so nobody can place an order that is already "Shipped".
Step 6: URLs and templates
# shop/urls.py
from django.urls import path
from . import views
app_name = "shop"
urlpatterns = [
path("", views.product_list, name="list"),
path("category/<slug:category_slug>/", views.product_list, name="category"),
path("cart/", views.cart_detail, name="cart"),
path("cart/add/<slug:slug>/", views.cart_add, name="cart-add"),
path("cart/remove/<slug:slug>/", views.cart_remove, name="cart-remove"),
path("checkout/", views.checkout, name="checkout"),
path("orders/<int:pk>/done/", views.order_done, name="done"),
path("<slug:slug>/", views.product_detail, name="product"),
]
# config/urls.py
# path("admin/", admin.site.urls),
# path("", include("shop.urls")),The catch-all product pattern <slug:slug>/ comes last, so cart/ and checkout/ are matched first. The templates extend a base.html that shows the shop name, a cart link and any messages. The two templates with forms look like this:
<!-- templates/shop/product_detail.html -->
{% extends "base.html" %}
{% block title %}{{ product.name }} | {{ block.super }}{% endblock %}
{% block content %}
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
<p>Rs {{ product.price }} | {{ product.stock }} in stock</p>
{% if product.stock %}
<form method="post" action="{% url 'shop:cart-add' product.slug %}">
{% csrf_token %}
<label>Quantity <input type="number" name="quantity" value="1" min="1" max="{{ product.stock }}"></label>
<button>Add to cart</button>
</form>
{% endif %}
{% endblock %}
<!-- templates/shop/cart.html -->
{% extends "base.html" %}
{% block content %}
<h1>Your cart</h1>
<table>
{% for line in cart.lines %}
<tr>
<td>{{ line.product.name }}</td><td>{{ line.quantity }}</td><td>Rs {{ line.total }}</td>
<td><form method="post" action="{% url 'shop:cart-remove' line.product.slug %}">{% csrf_token %}<button>Remove</button></form></td>
</tr>
{% empty %}
<tr><td>Your cart is empty.</td></tr>
{% endfor %}
</table>
<p>Total: Rs {{ cart.total }}</p>
{% if cart|length %}<a href="{% url 'shop:checkout' %}">Checkout</a>{% endif %}
{% endblock %}Templates can call methods without arguments, so cart.lines and cart.total work directly. A first version of this cart page used {% if cart.lines %} for the checkout link, and testing showed the link even on an empty cart: lines() returns a generator, and a generator object is always "true". cart|length calls __len__(), which gives the right answer. The product list, checkout and thank-you templates follow the same pattern: a {% for %} loop over products or order.items.all, {{ form }} with {% csrf_token %}, and the totals.
Step 7: Try the whole flow
With python manage.py runserver running, here is a full shopping session (page text only, collected with the test client):
GET / All products: Georgette dupatta Rs 899.00 | Pastel blue kurta Rs 1499.00 (out of stock) | White chikan kurta Rs 1299.00
GET /category/dupattas/ Dupattas: Georgette dupatta Rs 899.00
POST add kurta x 2 "Added White chikan kurta to your cart." Total: Rs 2598.00
POST add dupatta x 1 "Added Georgette dupatta to your cart." Total: Rs 3497.00
POST add blue kurta "Sorry, Pastel blue kurta is out of stock." Total: Rs 3497.00
POST checkout phone 12345 200, form shown again: Enter a 10-digit mobile number.
POST checkout (valid) 302 -> /orders/1/done/
GET /orders/1/done/ Thank you, Ananya! Order number 1 is new.
1 x Georgette dupatta = Rs 899.00
2 x White chikan kurta = Rs 2598.00
Total to pay on delivery: Rs 3497.00
Stock afterwards Georgette dupatta 7, Pastel blue kurta 0, White chikan kurta 3
GET /cart/ Your cart is empty. Total: Rs 0Stock dropped by exactly what was sold, the cart was cleared, and the order appears in the admin with its two lines.
Step 8: Tests for the money paths
The two rules that would cost the shop real money if they broke are "an order reduces stock correctly" and "you cannot buy stock that is gone". Test both:
# shop/tests.py
from decimal import Decimal
from django.test import TestCase
from .models import Category, Order, Product
class StoreTests(TestCase):
@classmethod
def setUpTestData(cls):
kurta = Category.objects.create(name="Kurtas", slug="kurtas")
cls.kurta = Product.objects.create(category=kurta, name="White chikan kurta",
slug="white-chikan-kurta", price=Decimal("1299.00"), stock=3)
def checkout(self):
return self.client.post("/checkout/", {"name": "Ananya", "phone": "9876543210",
"address": "12 Hazratganj, Lucknow"})
def test_checkout_creates_order_and_reduces_stock(self):
self.client.post("/cart/add/white-chikan-kurta/", {"quantity": "2"})
response = self.checkout()
order = Order.objects.get()
self.assertRedirects(response, f"/orders/{order.pk}/done/")
self.assertEqual(order.total, Decimal("2598.00"))
self.kurta.refresh_from_db()
self.assertEqual(self.kurta.stock, 1)
def test_cannot_buy_more_than_stock(self):
self.client.post("/cart/add/white-chikan-kurta/", {"quantity": "3"})
Product.objects.filter(pk=self.kurta.pk).update(stock=1) # someone else bought 2
response = self.checkout()
self.assertRedirects(response, "/cart/")
self.assertEqual(Order.objects.count(), 0) # the whole order was rolled backpython manage.py test shopFound 2 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). .. ---------------------------------------------------------------------- Ran 2 tests in 0.253s OK Destroying test database for alias 'default'...
The second test simulates the race: between adding to the cart and checking out, another customer buys two kurtas. The checkout fails cleanly, and Order.objects.count() is 0, which proves the transaction removed the half-created order.
Ideas to extend it
- Product photos with
ImageFieldand Pillow, as in Static & Media Files. - Customer accounts so people can see past orders: add a nullable
userforeign key toOrderand a "My orders" page with@login_required. - Order emails: send a confirmation with Django's mail functions, ideally from a background task.
- Online payments through a payment gateway's official SDK, with webhook verification and an extra
paidstatus. - A JSON API for a mobile app using Django REST Framework.
- Deployment with Gunicorn, WhiteNoise and environment settings, following the Deployment checklist and
check --deploy.
Key points
- Copy prices into order lines so history stays correct when prices change, and
PROTECTproducts that appear in orders. - A session-based cart stores only IDs and quantities; prices always come from the database.
- Change data only with POST views, redirect afterwards, and use messages for feedback.
transaction.atomicplusfilter(stock__gte=...).update(stock=F("stock") - n)prevents overselling, even with simultaneous orders.- Test the money paths (stock, totals, failures) so future changes cannot break them silently.
Exercise
Add a "Packed" button for staff. Create a view mark_packed(request, pk) at orders/<int:pk>/packed/ that only users with the shop.change_order permission can use, accepts only POST, changes a new order's status to packed, and redirects to the admin change page for that order. Orders that are not new should not change, and the view should show an error message instead.
Show solution
from django.contrib.auth.decorators import permission_required
from django.urls import reverse
@require_POST
@permission_required("shop.change_order", raise_exception=True)
def mark_packed(request, pk):
order = get_object_or_404(Order, pk=pk)
if order.status == Order.Status.NEW:
order.status = Order.Status.PACKED
order.save(update_fields=["status"])
messages.success(request, f"Order {order.pk} marked as packed.")
else:
messages.error(request, f"Order {order.pk} is already {order.get_status_display().lower()}.")
return redirect(reverse("admin:shop_order_change", args=[order.pk]))
# shop/urls.py, above the catch-all product pattern:
# path("orders/<int:pk>/packed/", views.mark_packed, name="packed"),POST /orders/1/packed/ (staff with permission) -> 302 /admin/shop/order/1/change/, "Order 1 marked as packed." POST /orders/1/packed/ again -> 302, "Order 1 is already packed." POST /orders/1/packed/ (customer, no permission) -> 403 GET /orders/1/packed/ -> 405
The admin names its URLs admin:<app>_<model>_change, so reverse() builds the link to the order's admin page. Checking the current status first means repeated clicks cannot move an order backwards or skip a step.
Quick Quiz
1. Why does OrderItem store its own price instead of reading product.price?
Order history must not change when the shop updates its prices.
2. What does the cart keep in the session?
Prices are read from the database each time, so they cannot be tampered with in the session.
3. Why is place_order() decorated with @transaction.atomic?
A transaction makes the order all-or-nothing.
4. How does the checkout avoid selling stock that another customer just bought?
The check and the decrement happen together in one SQL UPDATE.
Learn Django & Flask with a mentor
Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
