EthicsComputerCourses
Join training +91 90532 10052

Project: A Small Online Store

Django & Flask · Lesson 41 of 42 Advanced 9 min read Updated

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

PageURLWhat 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:

Terminal
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 shop

In 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.

Python
# 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.quantity

Three choices matter for a real shop:

  • OrderItem.price copies 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.
  • PROTECT on products and categories stops staff from deleting a product that appears in past orders. Hide it with is_active = False instead.
  • CASCADE from order to items: deleting an order (rare, but possible for a test order) removes its lines too.
Terminal
python manage.py makemigrations shop
python manage.py migrate
Output
Migrations for 'shop':
  shop\migrations\0001_initial.py
    + Create model Category
    + Create model Order
    + Create model Product
    + Create model OrderItem

Step 3: The admin

Staff need to add products, change prices and stock quickly, and see each order with its lines:

Python
# 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:

Python
# 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

Python
# 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:

Python
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 SQL UPDATE. If another order took the stock a moment earlier, the filter matches nothing, update() returns 0, and we stop.
  • @transaction.atomic wraps 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:

Python
# 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 phone

fields 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

Python
# 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:

HTML
<!-- 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):

Output
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 0

Stock 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:

Python
# 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 back
Terminal
python manage.py test shop
Output
Found 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 ImageField and Pillow, as in Static & Media Files.
  • Customer accounts so people can see past orders: add a nullable user foreign key to Order and 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 paid status.
  • 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 PROTECT products 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.atomic plus filter(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
Python
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"),
Output
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?

2. What does the cart keep in the session?

3. Why is place_order() decorated with @transaction.atomic?

4. How does the checkout avoid selling stock that another customer just bought?

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