Reading tutorials is one thing. Building actual projects is where the real learning happens. I've taught Django to dozens of developers, and the ones who succeed are the ones who build things. They break things. They fix things. They learn by doing.
In this chapter, I'll walk you through five mini projects. Each one introduces new concepts and reinforces what you've learned. Don't just read the code - type it. Run it. Break it. Then fix it. That's how you become a Django developer.
19.1 Project 1: Personal Blog with Comments (Beginner)
Every Django developer starts with a blog. It's the perfect first project - simple enough to finish, but complete enough to teach you the fundamentals.
# ========== PROJECT 1: PERSONAL BLOG ==========
# What you'll learn:
# - Models, views, templates
# - User authentication
# - Comments system
# - Basic admin customization
# ========== MODELS (models.py) ==========
from django.db import models
from django.contrib.auth.models import User
from django.utils.text import slugify
from django.urls import reverse
from django.utils import timezone
class Category(models.Model):
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def __str__(self):
return self.name
class Meta:
verbose_name_plural = 'Categories'
class Post(models.Model):
STATUS_CHOICES = [
('draft', 'Draft'),
('published', 'Published'),
]
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True, blank=True)
content = models.TextField()
excerpt = models.TextField(max_length=300, blank=True)
featured_image = models.ImageField(upload_to='blog_images/', blank=True, null=True)
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts')
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, related_name='posts')
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='draft')
views = models.IntegerField(default=0)
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
published_date = models.DateTimeField(null=True, blank=True)
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
# Handle duplicate slugs
original_slug = self.slug
counter = 1
while Post.objects.filter(slug=self.slug).exists():
self.slug = f"{original_slug}-{counter}"
counter += 1
if self.status == 'published' and not self.published_date:
self.published_date = timezone.now()
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse('blog:post_detail', args=[self.slug])
def __str__(self):
return self.title
class Meta:
ordering = ['-published_date']
class Comment(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
name = models.CharField(max_length=100)
email = models.EmailField()
content = models.TextField()
is_approved = models.BooleanField(default=False)
created_date = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Comment by {self.name} on {self.post.title}"
class Meta:
ordering = ['created_date']
# ========== VIEWS (views.py) ==========
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.core.paginator import Paginator
from django.db.models import Q, Count
from .models import Post, Category, Comment
from .forms import PostForm, CommentForm
def blog_home(request):
"""Homepage showing latest posts"""
posts = Post.objects.filter(status='published').select_related('author', 'category')
# Search functionality
search_query = request.GET.get('q')
if search_query:
posts = posts.filter(
Q(title__icontains=search_query) |
Q(content__icontains=search_query) |
Q(author__username__icontains=search_query)
)
# Pagination
paginator = Paginator(posts, 10)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
# Sidebar data
categories = Category.objects.annotate(post_count=Count('posts')).filter(post_count__gt=0)
recent_posts = Post.objects.filter(status='published')[:5]
context = {
'posts': page_obj,
'categories': categories,
'recent_posts': recent_posts,
'search_query': search_query,
}
return render(request, 'blog/home.html', context)
def post_detail(request, slug):
"""Display single post with comments"""
post = get_object_or_404(Post, slug=slug, status='published')
# Increment view count
post.views += 1
post.save(update_fields=['views'])
# Handle comment submission
if request.method == 'POST':
form = CommentForm(request.POST)
if form.is_valid():
comment = form.save(commit=False)
comment.post = post
comment.save()
messages.success(request, 'Your comment has been submitted and awaits approval.')
return redirect('blog:post_detail', slug=post.slug)
else:
form = CommentForm()
# Get approved comments
comments = post.comments.filter(is_approved=True)
context = {
'post': post,
'comments': comments,
'form': form,
}
return render(request, 'blog/post_detail.html', context)
@login_required
def post_create(request):
"""Create new blog post"""
if request.method == 'POST':
form = PostForm(request.POST, request.FILES)
if form.is_valid():
post = form.save(commit=False)
post.author = request.user
post.save()
messages.success(request, 'Post created successfully!')
return redirect('blog:post_detail', slug=post.slug)
else:
form = PostForm()
return render(request, 'blog/post_form.html', {'form': form, 'title': 'Create Post'})
@login_required
def post_edit(request, slug):
"""Edit existing post"""
post = get_object_or_404(Post, slug=slug)
# Check permission
if post.author != request.user and not request.user.is_staff:
messages.error(request, 'You do not have permission to edit this post.')
return redirect('blog:post_detail', slug=post.slug)
if request.method == 'POST':
form = PostForm(request.POST, request.FILES, instance=post)
if form.is_valid():
form.save()
messages.success(request, 'Post updated successfully!')
return redirect('blog:post_detail', slug=post.slug)
else:
form = PostForm(instance=post)
return render(request, 'blog/post_form.html', {'form': form, 'title': 'Edit Post', 'post': post})
@login_required
def post_delete(request, slug):
"""Delete post"""
post = get_object_or_404(Post, slug=slug)
if post.author != request.user and not request.user.is_staff:
messages.error(request, 'You do not have permission to delete this post.')
return redirect('blog:post_detail', slug=post.slug)
if request.method == 'POST':
post.delete()
messages.success(request, 'Post deleted successfully!')
return redirect('blog:home')
return render(request, 'blog/post_confirm_delete.html', {'post': post})
def category_posts(request, slug):
"""Display all posts in a category"""
category = get_object_or_404(Category, slug=slug)
posts = Post.objects.filter(category=category, status='published')
paginator = Paginator(posts, 10)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
return render(request, 'blog/category_posts.html', {'category': category, 'posts': page_obj})
# ========== FORMS (forms.py) ==========
from django import forms
from .models import Post, Comment
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ['title', 'category', 'content', 'excerpt', 'featured_image', 'status']
widgets = {
'content': forms.Textarea(attrs={'rows': 15, 'class': 'tinymce-editor'}),
'excerpt': forms.Textarea(attrs={'rows': 3}),
}
def clean_title(self):
title = self.cleaned_data.get('title')
if len(title) < 5:
raise forms.ValidationError('Title must be at least 5 characters long.')
return title
def clean_content(self):
content = self.cleaned_data.get('content')
if len(content) < 50:
raise forms.ValidationError('Content must be at least 50 characters long.')
return content
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ['name', 'email', 'content']
widgets = {
'content': forms.Textarea(attrs={'rows': 4, 'placeholder': 'Write your comment here...'}),
}
def clean_content(self):
content = self.cleaned_data.get('content')
if len(content) < 5:
raise forms.ValidationError('Comment must be at least 5 characters long.')
return content
# ========== TEMPLATES ==========
# templates/blog/base.html
"""
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}My Blog{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{% url 'blog:home' %}">My Blog</a>
<div class="navbar-nav">
<a class="nav-link" href="{% url 'blog:home' %}">Home</a>
{% if user.is_authenticated %}
<a class="nav-link" href="{% url 'blog:post_create' %}">New Post</a>
<a class="nav-link" href="{% url 'logout' %}">Logout ({{ user.username }})</a>
{% else %}
<a class="nav-link" href="{% url 'login' %}">Login</a>
<a class="nav-link" href="{% url 'register' %}">Register</a>
{% endif %}
</div>
</div>
</nav>
<div class="container mt-4">
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% block content %}{% endblock %}
</div>
</body>
</html>
"""
# Run this project:
# python manage.py makemigrations
# python manage.py migrate
# python manage.py createsuperuser
# python manage.py runserver
What you'll learn from Project 1: By building this blog, you'll understand the complete request-response cycle, how models relate to each other, how forms validate data, and how to protect views with login requirements. This is the foundation for everything else.
19.2 Project 2: To-Do App with User Authentication (Beginner)
A to-do app teaches you about user-specific data, CRUD operations, and basic AJAX.
# ========== PROJECT 2: TO-DO APP ==========
# What you'll learn:
# - User-specific queries (filter by user)
# - AJAX for real-time updates
# - Task completion toggle
# - Due dates and priorities
# ========== MODELS (todo/models.py) ==========
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
class Task(models.Model):
PRIORITY_CHOICES = [
('low', 'Low'),
('medium', 'Medium'),
('high', 'High'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tasks')
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default='medium')
due_date = models.DateTimeField(null=True, blank=True)
completed = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-priority', 'due_date', '-created_at']
indexes = [
models.Index(fields=['user', 'completed']),
models.Index(fields=['due_date']),
]
def __str__(self):
return self.title
def is_overdue(self):
if not self.completed and self.due_date:
return self.due_date < timezone.now()
return False
# ========== VIEWS (todo/views.py) ==========
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.core.paginator import Paginator
from .models import Task
from .forms import TaskForm
from datetime import datetime
@login_required
def task_list(request):
"""Display user's tasks with filtering"""
tasks = Task.objects.filter(user=request.user)
# Filter by status
filter_by = request.GET.get('filter', 'all')
if filter_by == 'active':
tasks = tasks.filter(completed=False)
elif filter_by == 'completed':
tasks = tasks.filter(completed=True)
elif filter_by == 'overdue':
tasks = tasks.filter(completed=False, due_date__lt=timezone.now())
# Filter by priority
priority = request.GET.get('priority')
if priority:
tasks = tasks.filter(priority=priority)
# Search
search = request.GET.get('search')
if search:
tasks = tasks.filter(title__icontains=search)
# Pagination
paginator = Paginator(tasks, 20)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
# Statistics
stats = {
'total': Task.objects.filter(user=request.user).count(),
'completed': Task.objects.filter(user=request.user, completed=True).count(),
'active': Task.objects.filter(user=request.user, completed=False).count(),
'overdue': Task.objects.filter(user=request.user, completed=False, due_date__lt=timezone.now()).count(),
}
context = {
'tasks': page_obj,
'stats': stats,
'current_filter': filter_by,
'current_priority': priority,
'search_query': search,
}
return render(request, 'todo/task_list.html', context)
@login_required
def task_create(request):
"""Create new task"""
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
task = form.save(commit=False)
task.user = request.user
task.save()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({'success': True, 'task_id': task.id})
messages.success(request, 'Task created successfully!')
return redirect('todo:task_list')
else:
form = TaskForm()
return render(request, 'todo/task_form.html', {'form': form, 'title': 'Create Task'})
@login_required
def task_edit(request, task_id):
"""Edit existing task"""
task = get_object_or_404(Task, id=task_id, user=request.user)
if request.method == 'POST':
form = TaskForm(request.POST, instance=task)
if form.is_valid():
form.save()
messages.success(request, 'Task updated successfully!')
return redirect('todo:task_list')
else:
form = TaskForm(instance=task)
return render(request, 'todo/task_form.html', {'form': form, 'title': 'Edit Task', 'task': task})
@login_required
@require_http_methods(['POST'])
def task_toggle(request, task_id):
"""Toggle task completion status (AJAX)"""
task = get_object_or_404(Task, id=task_id, user=request.user)
task.completed = not task.completed
if task.completed:
from django.utils import timezone
task.completed_at = timezone.now()
task.save()
return JsonResponse({
'success': True,
'completed': task.completed,
'task_id': task.id
})
@login_required
@require_http_methods(['POST'])
def task_delete(request, task_id):
"""Delete task (AJAX supported)"""
task = get_object_or_404(Task, id=task_id, user=request.user)
task.delete()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({'success': True})
messages.success(request, 'Task deleted successfully!')
return redirect('todo:task_list')
@login_required
def task_bulk_action(request):
"""Bulk actions for tasks"""
if request.method == 'POST':
task_ids = request.POST.getlist('task_ids')
action = request.POST.get('action')
tasks = Task.objects.filter(id__in=task_ids, user=request.user)
if action == 'complete':
tasks.update(completed=True)
messages.success(request, f'{tasks.count()} tasks marked as complete.')
elif action == 'delete':
tasks.delete()
messages.success(request, f'{tasks.count()} tasks deleted.')
return redirect('todo:task_list')
return redirect('todo:task_list')
# ========== FORMS (todo/forms.py) ==========
from django import forms
from .models import Task
class TaskForm(forms.ModelForm):
due_date = forms.DateTimeField(
required=False,
widget=forms.DateTimeInput(attrs={'type': 'datetime-local'}),
input_formats=['%Y-%m-%dT%H:%M']
)
class Meta:
model = Task
fields = ['title', 'description', 'priority', 'due_date']
widgets = {
'description': forms.Textarea(attrs={'rows': 3}),
}
def clean_title(self):
title = self.cleaned_data.get('title')
if len(title) < 3:
raise forms.ValidationError('Title must be at least 3 characters.')
return title
# ========== JAVASCRIPT FOR AJAX (todo/templates/todo/task_list.html) ==========
"""
<script>
function toggleTask(taskId, checkbox) {
fetch(`/tasks/${taskId}/toggle/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (data.completed) {
checkbox.closest('tr').classList.add('table-success');
} else {
checkbox.closest('tr').classList.remove('table-success');
}
updateStats();
}
});
}
function deleteTask(taskId, element) {
if (confirm('Are you sure?')) {
fetch(`/tasks/${taskId}/delete/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
element.closest('tr').remove();
updateStats();
}
});
}
}
function updateStats() {
// Update statistics without page reload
fetch('/tasks/stats/')
.then(response => response.json())
.then(data => {
document.getElementById('total-count').innerText = data.total;
document.getElementById('completed-count').innerText = data.completed;
document.getElementById('active-count').innerText = data.active;
});
}
</script>
"""
# URLs for this project:
# path('', task_list, name='task_list'),
# path('create/', task_create, name='task_create'),
# path('<int:task_id>/edit/', task_edit, name='task_edit'),
# path('<int:task_id>/toggle/', task_toggle, name='task_toggle'),
# path('<int:task_id>/delete/', task_delete, name='task_delete'),
# path('bulk-action/', task_bulk_action, name='task_bulk_action'),
What you'll learn from Project 2: User-specific data filtering, AJAX for smooth user experience, bulk operations, and real-time UI updates. This project is perfect for understanding how Django handles data ownership.
19.3 Project 3: E-commerce Product Catalog with Cart (Intermediate)
An e-commerce project teaches you sessions, complex relationships, and payment integration basics.
# ========== PROJECT 3: E-COMMERCE CATALOG ==========
# What you'll learn:
# - Session-based shopping cart
# - Product variations (size, color)
# - Discount coupons
# - Order processing
# - Payment gateway integration (Stripe)
# ========== MODELS (store/models.py) ==========
from django.db import models
from django.contrib.auth.models import User
from django.core.validators import MinValueValidator, MaxValueValidator
from decimal import Decimal
class Category(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
image = models.ImageField(upload_to='categories/', blank=True)
def __str__(self):
return self.name
class Meta:
verbose_name_plural = 'Categories'
class Product(models.Model):
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='products')
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
description = models.TextField()
price = models.DecimalField(max_digits=10, decimal_places=2)
compare_price = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True, help_text="Original price for discount display")
stock = models.IntegerField(default=0)
available = models.BooleanField(default=True)
featured = models.BooleanField(default=False)
image = models.ImageField(upload_to='products/')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
def get_discount_percent(self):
if self.compare_price and self.compare_price > self.price:
return int(((self.compare_price - self.price) / self.compare_price) * 100)
return 0
@property
def in_stock(self):
return self.stock > 0
class ProductVariation(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='variations')
size = models.CharField(max_length=20, blank=True)
color = models.CharField(max_length=50, blank=True)
stock = models.IntegerField(default=0)
price_adjustment = models.DecimalField(max_digits=10, decimal_places=2, default=0)
def __str__(self):
return f"{self.product.name} - {self.size} {self.color}"
class Cart(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, null=True, blank=True)
session_key = models.CharField(max_length=40, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"Cart {self.id}"
@property
def total(self):
return sum(item.total for item in self.items.all())
@property
def item_count(self):
return sum(item.quantity for item in self.items.all())
class CartItem(models.Model):
cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name='items')
product = models.ForeignKey(Product, on_delete=models.CASCADE)
variation = models.ForeignKey(ProductVariation, on_delete=models.SET_NULL, null=True, blank=True)
quantity = models.IntegerField(default=1, validators=[MinValueValidator(1)])
@property
def total(self):
return self.product.price * self.quantity
class Order(models.Model):
STATUS_CHOICES = [
('pending', 'Pending'),
('processing', 'Processing'),
('paid', 'Paid'),
('shipped', 'Shipped'),
('delivered', 'Delivered'),
('cancelled', 'Cancelled'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='orders')
order_number = models.CharField(max_length=20, unique=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
# Shipping information
full_name = models.CharField(max_length=200)
email = models.EmailField()
phone = models.CharField(max_length=20)
address = models.TextField()
city = models.CharField(max_length=100)
postal_code = models.CharField(max_length=20)
# Order totals
subtotal = models.DecimalField(max_digits=10, decimal_places=2)
discount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
shipping_cost = models.DecimalField(max_digits=10, decimal_places=2, default=0)
total = models.DecimalField(max_digits=10, decimal_places=2)
# Payment
payment_method = models.CharField(max_length=50)
payment_id = models.CharField(max_length=200, blank=True)
paid_at = models.DateTimeField(null=True, blank=True)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def save(self, *args, **kwargs):
if not self.order_number:
import random
import string
self.order_number = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10))
super().save(*args, **kwargs)
def __str__(self):
return f"Order {self.order_number}"
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items')
product = models.ForeignKey(Product, on_delete=models.CASCADE)
product_name = models.CharField(max_length=200) # Snapshot of product name
product_price = models.DecimalField(max_digits=10, decimal_places=2) # Snapshot of price
quantity = models.IntegerField()
variation = models.CharField(max_length=100, blank=True)
class Coupon(models.Model):
code = models.CharField(max_length=50, unique=True)
discount_type = models.CharField(max_length=20, choices=[('percentage', 'Percentage'), ('fixed', 'Fixed Amount')])
discount_value = models.DecimalField(max_digits=10, decimal_places=2)
minimum_order = models.DecimalField(max_digits=10, decimal_places=2, default=0)
valid_from = models.DateTimeField()
valid_to = models.DateTimeField()
usage_limit = models.IntegerField(default=1)
used_count = models.IntegerField(default=0)
active = models.BooleanField(default=True)
def is_valid(self):
from django.utils import timezone
return (self.active and
self.valid_from <= timezone.now() <= self.valid_to and
self.used_count < self.usage_limit)
# ========== CART VIEWS (store/views_cart.py) ==========
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.contrib import messages
from .models import Cart, CartItem, Product, Coupon
def get_or_create_cart(request):
"""Get existing cart or create new one"""
if request.user.is_authenticated:
cart, created = Cart.objects.get_or_create(user=request.user)
else:
session_key = request.session.session_key
if not session_key:
request.session.create()
session_key = request.session.session_key
cart, created = Cart.objects.get_or_create(session_key=session_key)
return cart
def add_to_cart(request, product_id):
"""Add product to cart (AJAX supported)"""
cart = get_or_create_cart(request)
product = get_object_or_404(Product, id=product_id, available=True)
variation_id = request.POST.get('variation')
quantity = int(request.POST.get('quantity', 1))
# Check if item already exists in cart
cart_item = CartItem.objects.filter(cart=cart, product=product).first()
if cart_item:
cart_item.quantity += quantity
cart_item.save()
else:
CartItem.objects.create(
cart=cart,
product=product,
quantity=quantity
)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({
'success': True,
'cart_count': cart.item_count,
'cart_total': float(cart.total)
})
messages.success(request, f'{product.name} added to cart!')
return redirect('store:cart_detail')
def cart_detail(request):
"""Display cart contents"""
cart = get_or_create_cart(request)
# Apply coupon if present
coupon_code = request.session.get('coupon_code')
discount = 0
if coupon_code:
try:
coupon = Coupon.objects.get(code=coupon_code, active=True)
if coupon.is_valid() and cart.total >= coupon.minimum_order:
if coupon.discount_type == 'percentage':
discount = cart.total * (coupon.discount_value / 100)
else:
discount = coupon.discount_value
except Coupon.DoesNotExist:
del request.session['coupon_code']
context = {
'cart': cart,
'cart_items': cart.items.all().select_related('product'),
'subtotal': cart.total,
'discount': discount,
'total': cart.total - discount,
}
return render(request, 'store/cart.html', context)
def update_cart(request, item_id):
"""Update cart item quantity"""
if request.method == 'POST':
cart_item = get_object_or_404(CartItem, id=item_id)
quantity = int(request.POST.get('quantity', 1))
if quantity > 0:
cart_item.quantity = quantity
cart_item.save()
else:
cart_item.delete()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
cart = cart_item.cart
return JsonResponse({
'success': True,
'item_total': float(cart_item.total),
'cart_total': float(cart.total),
'item_count': cart.item_count
})
return redirect('store:cart_detail')
def remove_from_cart(request, item_id):
"""Remove item from cart"""
cart_item = get_object_or_404(CartItem, id=item_id)
cart_item.delete()
messages.success(request, 'Item removed from cart.')
return redirect('store:cart_detail')
def apply_coupon(request):
"""Apply discount coupon"""
if request.method == 'POST':
coupon_code = request.POST.get('coupon_code')
try:
coupon = Coupon.objects.get(code=coupon_code, active=True)
cart = get_or_create_cart(request)
if coupon.is_valid() and cart.total >= coupon.minimum_order:
request.session['coupon_code'] = coupon_code
messages.success(request, f'Coupon {coupon_code} applied!')
else:
messages.error(request, 'Coupon is invalid or expired.')
except Coupon.DoesNotExist:
messages.error(request, 'Invalid coupon code.')
return redirect('store:cart_detail')
# ========== STRIPE PAYMENT INTEGRATION ==========
# Install: pip install stripe
import stripe
from django.conf import settings
from django.views.decorators.csrf import csrf_exempt
stripe.api_key = settings.STRIPE_SECRET_KEY
@login_required
def checkout(request):
"""Checkout page"""
cart = get_or_create_cart(request)
if cart.item_count == 0:
messages.warning(request, 'Your cart is empty.')
return redirect('store:cart_detail')
if request.method == 'POST':
# Create Stripe payment intent
intent = stripe.PaymentIntent.create(
amount=int(cart.total * 100), # Stripe uses cents
currency='usd',
metadata={'cart_id': cart.id, 'user_id': request.user.id}
)
return render(request, 'store/checkout.html', {
'client_secret': intent.client_secret,
'stripe_public_key': settings.STRIPE_PUBLIC_KEY,
'cart': cart
})
return render(request, 'store/checkout.html', {'cart': cart})
@csrf_exempt
def stripe_webhook(request):
"""Handle Stripe webhook for payment confirmation"""
payload = request.body
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except ValueError:
return HttpResponse(status=400)
except stripe.error.SignatureVerificationError:
return HttpResponse(status=400)
if event['type'] == 'payment_intent.succeeded':
intent = event['data']['object']
# Create order from cart
cart_id = intent['metadata']['cart_id']
cart = Cart.objects.get(id=cart_id)
order = Order.objects.create(
user=request.user,
status='paid',
subtotal=cart.total,
total=cart.total,
# ... other fields
)
# Move cart items to order items
for item in cart.items.all():
OrderItem.objects.create(
order=order,
product=item.product,
product_name=item.product.name,
product_price=item.product.price,
quantity=item.quantity
)
# Reduce stock
item.product.stock -= item.quantity
item.product.save()
# Clear cart
cart.items.all().delete()
return HttpResponse(status=200)
19.4 Project 4: REST API for a Task Manager with DRF (Intermediate)
Build a complete API that can serve a React or mobile frontend.
# ========== PROJECT 4: TASK MANAGER API ==========
# What you'll learn:
# - Django REST Framework setup
# - Token authentication
# - Nested serializers
# - Filtering, searching, pagination
# - API documentation
# ========== SERIALIZERS (api/serializers.py) ==========
from rest_framework import serializers
from django.contrib.auth.models import User
from todo.models import Task, Category
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'first_name', 'last_name']
class TaskSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
is_overdue = serializers.SerializerMethodField()
class Meta:
model = Task
fields = ['id', 'title', 'description', 'completed', 'priority',
'due_date', 'created_at', 'user', 'is_overdue']
read_only_fields = ['id', 'created_at', 'user']
def get_is_overdue(self, obj):
return obj.is_overdue()
def create(self, validated_data):
request = self.context.get('request')
validated_data['user'] = request.user
return super().create(validated_data)
class TaskCreateSerializer(serializers.ModelSerializer):
class Meta:
model = Task
fields = ['title', 'description', 'priority', 'due_date']
# ========== VIEWS (api/views.py) ==========
from rest_framework import viewsets, status, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework.authentication import TokenAuthentication
from django_filters.rest_framework import DjangoFilterBackend
from .models import Task
from .serializers import TaskSerializer, TaskCreateSerializer
class TaskViewSet(viewsets.ModelViewSet):
serializer_class = TaskSerializer
permission_classes = [IsAuthenticated]
authentication_classes = [TokenAuthentication]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['completed', 'priority']
search_fields = ['title', 'description']
ordering_fields = ['created_at', 'due_date', 'priority']
ordering = ['-created_at']
def get_queryset(self):
return Task.objects.filter(user=self.request.user)
def get_serializer_class(self):
if self.action == 'create':
return TaskCreateSerializer
return TaskSerializer
@action(detail=True, methods=['post'])
def toggle_complete(self, request, pk=None):
task = self.get_object()
task.completed = not task.completed
task.save()
return Response({'status': 'toggled', 'completed': task.completed})
@action(detail=False, methods=['get'])
def stats(self, request):
tasks = self.get_queryset()
stats = {
'total': tasks.count(),
'completed': tasks.filter(completed=True).count(),
'pending': tasks.filter(completed=False).count(),
'overdue': tasks.filter(completed=False, due_date__lt=timezone.now()).count(),
}
return Response(stats)
@action(detail=False, methods=['post'])
def bulk_delete(self, request):
task_ids = request.data.get('task_ids', [])
deleted = Task.objects.filter(id__in=task_ids, user=request.user).delete()
return Response({'deleted': deleted[0]})
# ========== URLS (api/urls.py) ==========
from rest_framework.routers import DefaultRouter
from django.urls import path, include
from . import views
router = DefaultRouter()
router.register(r'tasks', views.TaskViewSet)
urlpatterns = [
path('', include(router.urls)),
path('auth/', include('rest_framework.urls')),
]
# ========== API TESTING ==========
# Get token: POST /api/token/ with username/password
# Use token: Authorization: Token your_token_here
# List tasks: GET /api/tasks/
# Create task: POST /api/tasks/
# Toggle task: POST /api/tasks/1/toggle_complete/
# Get stats: GET /api/tasks/stats/
19.5 Project 5: Real-time Chat App with Django Channels (Advanced)
WebSockets take Django beyond HTTP. Build a real-time chat application.
# ========== PROJECT 5: REAL-TIME CHAT ==========
# Install: pip install channels channels-redis
# ========== SETTINGS (settings.py) ==========
INSTALLED_APPS = [
'channels',
'chat',
]
ASGI_APPLICATION = 'myproject.asgi.application'
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
'hosts': [('127.0.0.1', 6379)],
},
},
}
# ========== MODELS (chat/models.py) ==========
from django.db import models
from django.contrib.auth.models import User
class Room(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
participants = models.ManyToManyField(User, related_name='chat_rooms')
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class Message(models.Model):
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name='messages')
user = models.ForeignKey(User, on_delete=models.CASCADE)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
# ========== CONSUMER (chat/consumers.py) ==========
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from .models import Room, Message
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.room_slug = self.scope['url_route']['kwargs']['room_slug']
self.room_group_name = f'chat_{self.room_slug}'
# Join room group
await self.channel_layer.group_add(
self.room_group_name,
self.channel_name
)
await self.accept()
async def disconnect(self, close_code):
# Leave room group
await self.channel_layer.group_discard(
self.room_group_name,
self.channel_name
)
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message = text_data_json['message']
username = self.scope['user'].username
# Save message to database
await self.save_message(username, message)
# Send message to room group
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'chat_message',
'message': message,
'username': username,
'timestamp': str(timezone.now())
}
)
async def chat_message(self, event):
# Send message to WebSocket
await self.send(text_data=json.dumps({
'message': event['message'],
'username': event['username'],
'timestamp': event['timestamp']
}))
@database_sync_to_async
def save_message(self, username, message):
from django.contrib.auth.models import User
user = User.objects.get(username=username)
room = Room.objects.get(slug=self.room_slug)
Message.objects.create(room=room, user=user, content=message)
# ========== ROUTING (chat/routing.py) ==========
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r'ws/chat/(?P[^/]+)/$', consumers.ChatConsumer.as_asgi()),
]
# ========== ASGI (asgi.py) ==========
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from chat.routing import websocket_urlpatterns
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
application = ProtocolTypeRouter({
'http': get_asgi_application(),
'websocket': AuthMiddlewareStack(
URLRouter(websocket_urlpatterns)
),
})
# ========== TEMPLATE (chat/templates/chat/room.html) ==========
"""
<div id="chat-messages">
{% for message in messages %}
<div>
<strong>{{ message.user.username }}:</strong>
{{ message.content }}
<small>{{ message.created_at|timesince }} ago</small>
</div>
{% endfor %}
</div>
<input id="chat-input" type="text">
<button id="send-button">Send</button>
<script>
const chatSocket = new WebSocket(
'ws://' + window.location.host + '/ws/chat/{{ room.slug }}/'
);
chatSocket.onmessage = function(e) {
const data = JSON.parse(e.data);
const messageDiv = document.createElement('div');
messageDiv.innerHTML = `<strong>${data.username}:</strong> ${data.message}`;
document.getElementById('chat-messages').appendChild(messageDiv);
};
document.getElementById('send-button').onclick = function(e) {
const input = document.getElementById('chat-input');
chatSocket.send(JSON.stringify({
'message': input.value
}));
input.value = '';
};
</script>
"""
Project Completion Checklist
- ✅ Build Project 1 (Blog) - Understand Django fundamentals
- ✅ Build Project 2 (To-Do) - Master user-specific data and AJAX
- ✅ Build Project 3 (E-commerce) - Learn sessions, carts, and payments
- ✅ Build Project 4 (API) - Create REST APIs for modern frontends
- ✅ Build Project 5 (Chat) - Real-time features with WebSockets
Summary
Building these five projects will take you from beginner to job-ready. Each project builds on the previous one, adding new skills and concepts. Don't rush. Build each one completely. Break them. Fix them. Make them your own.
In the final chapter, we'll walk through a real-world case study - building a complete SaaS platform from requirements to deployment.
Chapter 19: Mini Projects for Hands-On Learning
Reading tutorials is one thing. Building actual projects is where the real learning happens. I've taught Django to dozens of developers, and the ones who succeed are the ones who build things. They break things. They fix things. They learn by doing.
In this chapter, I'll walk you through five mini projects. Each one introduces new concepts and reinforces what you've learned. Don't just read the code - type it. Run it. Break it. Then fix it. That's how you become a Django developer.
19.1 Project 1: Personal Blog with Comments (Beginner)
Every Django developer starts with a blog. It's the perfect first project - simple enough to finish, but complete enough to teach you the fundamentals.
# ========== PROJECT 1: PERSONAL BLOG ==========
# What you'll learn:
# - Models, views, templates
# - User authentication
# - Comments system
# - Basic admin customization
# ========== MODELS (models.py) ==========
from django.db import models
from django.contrib.auth.models import User
from django.utils.text import slugify
from django.urls import reverse
from django.utils import timezone
class Category(models.Model):
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def __str__(self):
return self.name
class Meta:
verbose_name_plural = 'Categories'
class Post(models.Model):
STATUS_CHOICES = [
('draft', 'Draft'),
('published', 'Published'),
]
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True, blank=True)
content = models.TextField()
excerpt = models.TextField(max_length=300, blank=True)
featured_image = models.ImageField(upload_to='blog_images/', blank=True, null=True)
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts')
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, related_name='posts')
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='draft')
views = models.IntegerField(default=0)
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
published_date = models.DateTimeField(null=True, blank=True)
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
# Handle duplicate slugs
original_slug = self.slug
counter = 1
while Post.objects.filter(slug=self.slug).exists():
self.slug = f"{original_slug}-{counter}"
counter += 1
if self.status == 'published' and not self.published_date:
self.published_date = timezone.now()
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse('blog:post_detail', args=[self.slug])
def __str__(self):
return self.title
class Meta:
ordering = ['-published_date']
class Comment(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
name = models.CharField(max_length=100)
email = models.EmailField()
content = models.TextField()
is_approved = models.BooleanField(default=False)
created_date = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Comment by {self.name} on {self.post.title}"
class Meta:
ordering = ['created_date']
# ========== VIEWS (views.py) ==========
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.core.paginator import Paginator
from django.db.models import Q, Count
from .models import Post, Category, Comment
from .forms import PostForm, CommentForm
def blog_home(request):
"""Homepage showing latest posts"""
posts = Post.objects.filter(status='published').select_related('author', 'category')
# Search functionality
search_query = request.GET.get('q')
if search_query:
posts = posts.filter(
Q(title__icontains=search_query) |
Q(content__icontains=search_query) |
Q(author__username__icontains=search_query)
)
# Pagination
paginator = Paginator(posts, 10)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
# Sidebar data
categories = Category.objects.annotate(post_count=Count('posts')).filter(post_count__gt=0)
recent_posts = Post.objects.filter(status='published')[:5]
context = {
'posts': page_obj,
'categories': categories,
'recent_posts': recent_posts,
'search_query': search_query,
}
return render(request, 'blog/home.html', context)
def post_detail(request, slug):
"""Display single post with comments"""
post = get_object_or_404(Post, slug=slug, status='published')
# Increment view count
post.views += 1
post.save(update_fields=['views'])
# Handle comment submission
if request.method == 'POST':
form = CommentForm(request.POST)
if form.is_valid():
comment = form.save(commit=False)
comment.post = post
comment.save()
messages.success(request, 'Your comment has been submitted and awaits approval.')
return redirect('blog:post_detail', slug=post.slug)
else:
form = CommentForm()
# Get approved comments
comments = post.comments.filter(is_approved=True)
context = {
'post': post,
'comments': comments,
'form': form,
}
return render(request, 'blog/post_detail.html', context)
@login_required
def post_create(request):
"""Create new blog post"""
if request.method == 'POST':
form = PostForm(request.POST, request.FILES)
if form.is_valid():
post = form.save(commit=False)
post.author = request.user
post.save()
messages.success(request, 'Post created successfully!')
return redirect('blog:post_detail', slug=post.slug)
else:
form = PostForm()
return render(request, 'blog/post_form.html', {'form': form, 'title': 'Create Post'})
@login_required
def post_edit(request, slug):
"""Edit existing post"""
post = get_object_or_404(Post, slug=slug)
# Check permission
if post.author != request.user and not request.user.is_staff:
messages.error(request, 'You do not have permission to edit this post.')
return redirect('blog:post_detail', slug=post.slug)
if request.method == 'POST':
form = PostForm(request.POST, request.FILES, instance=post)
if form.is_valid():
form.save()
messages.success(request, 'Post updated successfully!')
return redirect('blog:post_detail', slug=post.slug)
else:
form = PostForm(instance=post)
return render(request, 'blog/post_form.html', {'form': form, 'title': 'Edit Post', 'post': post})
@login_required
def post_delete(request, slug):
"""Delete post"""
post = get_object_or_404(Post, slug=slug)
if post.author != request.user and not request.user.is_staff:
messages.error(request, 'You do not have permission to delete this post.')
return redirect('blog:post_detail', slug=post.slug)
if request.method == 'POST':
post.delete()
messages.success(request, 'Post deleted successfully!')
return redirect('blog:home')
return render(request, 'blog/post_confirm_delete.html', {'post': post})
def category_posts(request, slug):
"""Display all posts in a category"""
category = get_object_or_404(Category, slug=slug)
posts = Post.objects.filter(category=category, status='published')
paginator = Paginator(posts, 10)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
return render(request, 'blog/category_posts.html', {'category': category, 'posts': page_obj})
# ========== FORMS (forms.py) ==========
from django import forms
from .models import Post, Comment
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ['title', 'category', 'content', 'excerpt', 'featured_image', 'status']
widgets = {
'content': forms.Textarea(attrs={'rows': 15, 'class': 'tinymce-editor'}),
'excerpt': forms.Textarea(attrs={'rows': 3}),
}
def clean_title(self):
title = self.cleaned_data.get('title')
if len(title) < 5:
raise forms.ValidationError('Title must be at least 5 characters long.')
return title
def clean_content(self):
content = self.cleaned_data.get('content')
if len(content) < 50:
raise forms.ValidationError('Content must be at least 50 characters long.')
return content
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ['name', 'email', 'content']
widgets = {
'content': forms.Textarea(attrs={'rows': 4, 'placeholder': 'Write your comment here...'}),
}
def clean_content(self):
content = self.cleaned_data.get('content')
if len(content) < 5:
raise forms.ValidationError('Comment must be at least 5 characters long.')
return content
# ========== TEMPLATES ==========
# templates/blog/base.html
"""
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}My Blog{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{% url 'blog:home' %}">My Blog</a>
<div class="navbar-nav">
<a class="nav-link" href="{% url 'blog:home' %}">Home</a>
{% if user.is_authenticated %}
<a class="nav-link" href="{% url 'blog:post_create' %}">New Post</a>
<a class="nav-link" href="{% url 'logout' %}">Logout ({{ user.username }})</a>
{% else %}
<a class="nav-link" href="{% url 'login' %}">Login</a>
<a class="nav-link" href="{% url 'register' %}">Register</a>
{% endif %}
</div>
</div>
</nav>
<div class="container mt-4">
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% block content %}{% endblock %}
</div>
</body>
</html>
"""
# Run this project:
# python manage.py makemigrations
# python manage.py migrate
# python manage.py createsuperuser
# python manage.py runserver
What you'll learn from Project 1: By building this blog, you'll understand the complete request-response cycle, how models relate to each other, how forms validate data, and how to protect views with login requirements. This is the foundation for everything else.
19.2 Project 2: To-Do App with User Authentication (Beginner)
A to-do app teaches you about user-specific data, CRUD operations, and basic AJAX.
# ========== PROJECT 2: TO-DO APP ==========
# What you'll learn:
# - User-specific queries (filter by user)
# - AJAX for real-time updates
# - Task completion toggle
# - Due dates and priorities
# ========== MODELS (todo/models.py) ==========
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
class Task(models.Model):
PRIORITY_CHOICES = [
('low', 'Low'),
('medium', 'Medium'),
('high', 'High'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tasks')
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default='medium')
due_date = models.DateTimeField(null=True, blank=True)
completed = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-priority', 'due_date', '-created_at']
indexes = [
models.Index(fields=['user', 'completed']),
models.Index(fields=['due_date']),
]
def __str__(self):
return self.title
def is_overdue(self):
if not self.completed and self.due_date:
return self.due_date < timezone.now()
return False
# ========== VIEWS (todo/views.py) ==========
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.core.paginator import Paginator
from .models import Task
from .forms import TaskForm
from datetime import datetime
@login_required
def task_list(request):
"""Display user's tasks with filtering"""
tasks = Task.objects.filter(user=request.user)
# Filter by status
filter_by = request.GET.get('filter', 'all')
if filter_by == 'active':
tasks = tasks.filter(completed=False)
elif filter_by == 'completed':
tasks = tasks.filter(completed=True)
elif filter_by == 'overdue':
tasks = tasks.filter(completed=False, due_date__lt=timezone.now())
# Filter by priority
priority = request.GET.get('priority')
if priority:
tasks = tasks.filter(priority=priority)
# Search
search = request.GET.get('search')
if search:
tasks = tasks.filter(title__icontains=search)
# Pagination
paginator = Paginator(tasks, 20)
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
# Statistics
stats = {
'total': Task.objects.filter(user=request.user).count(),
'completed': Task.objects.filter(user=request.user, completed=True).count(),
'active': Task.objects.filter(user=request.user, completed=False).count(),
'overdue': Task.objects.filter(user=request.user, completed=False, due_date__lt=timezone.now()).count(),
}
context = {
'tasks': page_obj,
'stats': stats,
'current_filter': filter_by,
'current_priority': priority,
'search_query': search,
}
return render(request, 'todo/task_list.html', context)
@login_required
def task_create(request):
"""Create new task"""
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
task = form.save(commit=False)
task.user = request.user
task.save()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({'success': True, 'task_id': task.id})
messages.success(request, 'Task created successfully!')
return redirect('todo:task_list')
else:
form = TaskForm()
return render(request, 'todo/task_form.html', {'form': form, 'title': 'Create Task'})
@login_required
def task_edit(request, task_id):
"""Edit existing task"""
task = get_object_or_404(Task, id=task_id, user=request.user)
if request.method == 'POST':
form = TaskForm(request.POST, instance=task)
if form.is_valid():
form.save()
messages.success(request, 'Task updated successfully!')
return redirect('todo:task_list')
else:
form = TaskForm(instance=task)
return render(request, 'todo/task_form.html', {'form': form, 'title': 'Edit Task', 'task': task})
@login_required
@require_http_methods(['POST'])
def task_toggle(request, task_id):
"""Toggle task completion status (AJAX)"""
task = get_object_or_404(Task, id=task_id, user=request.user)
task.completed = not task.completed
if task.completed:
from django.utils import timezone
task.completed_at = timezone.now()
task.save()
return JsonResponse({
'success': True,
'completed': task.completed,
'task_id': task.id
})
@login_required
@require_http_methods(['POST'])
def task_delete(request, task_id):
"""Delete task (AJAX supported)"""
task = get_object_or_404(Task, id=task_id, user=request.user)
task.delete()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({'success': True})
messages.success(request, 'Task deleted successfully!')
return redirect('todo:task_list')
@login_required
def task_bulk_action(request):
"""Bulk actions for tasks"""
if request.method == 'POST':
task_ids = request.POST.getlist('task_ids')
action = request.POST.get('action')
tasks = Task.objects.filter(id__in=task_ids, user=request.user)
if action == 'complete':
tasks.update(completed=True)
messages.success(request, f'{tasks.count()} tasks marked as complete.')
elif action == 'delete':
tasks.delete()
messages.success(request, f'{tasks.count()} tasks deleted.')
return redirect('todo:task_list')
return redirect('todo:task_list')
# ========== FORMS (todo/forms.py) ==========
from django import forms
from .models import Task
class TaskForm(forms.ModelForm):
due_date = forms.DateTimeField(
required=False,
widget=forms.DateTimeInput(attrs={'type': 'datetime-local'}),
input_formats=['%Y-%m-%dT%H:%M']
)
class Meta:
model = Task
fields = ['title', 'description', 'priority', 'due_date']
widgets = {
'description': forms.Textarea(attrs={'rows': 3}),
}
def clean_title(self):
title = self.cleaned_data.get('title')
if len(title) < 3:
raise forms.ValidationError('Title must be at least 3 characters.')
return title
# ========== JAVASCRIPT FOR AJAX (todo/templates/todo/task_list.html) ==========
"""
<script>
function toggleTask(taskId, checkbox) {
fetch(`/tasks/${taskId}/toggle/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (data.completed) {
checkbox.closest('tr').classList.add('table-success');
} else {
checkbox.closest('tr').classList.remove('table-success');
}
updateStats();
}
});
}
function deleteTask(taskId, element) {
if (confirm('Are you sure?')) {
fetch(`/tasks/${taskId}/delete/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
element.closest('tr').remove();
updateStats();
}
});
}
}
function updateStats() {
// Update statistics without page reload
fetch('/tasks/stats/')
.then(response => response.json())
.then(data => {
document.getElementById('total-count').innerText = data.total;
document.getElementById('completed-count').innerText = data.completed;
document.getElementById('active-count').innerText = data.active;
});
}
</script>
"""
# URLs for this project:
# path('', task_list, name='task_list'),
# path('create/', task_create, name='task_create'),
# path('<int:task_id>/edit/', task_edit, name='task_edit'),
# path('<int:task_id>/toggle/', task_toggle, name='task_toggle'),
# path('<int:task_id>/delete/', task_delete, name='task_delete'),
# path('bulk-action/', task_bulk_action, name='task_bulk_action'),
What you'll learn from Project 2: User-specific data filtering, AJAX for smooth user experience, bulk operations, and real-time UI updates. This project is perfect for understanding how Django handles data ownership.
19.3 Project 3: E-commerce Product Catalog with Cart (Intermediate)
An e-commerce project teaches you sessions, complex relationships, and payment integration basics.
# ========== PROJECT 3: E-COMMERCE CATALOG ==========
# What you'll learn:
# - Session-based shopping cart
# - Product variations (size, color)
# - Discount coupons
# - Order processing
# - Payment gateway integration (Stripe)
# ========== MODELS (store/models.py) ==========
from django.db import models
from django.contrib.auth.models import User
from django.core.validators import MinValueValidator, MaxValueValidator
from decimal import Decimal
class Category(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
image = models.ImageField(upload_to='categories/', blank=True)
def __str__(self):
return self.name
class Meta:
verbose_name_plural = 'Categories'
class Product(models.Model):
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='products')
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
description = models.TextField()
price = models.DecimalField(max_digits=10, decimal_places=2)
compare_price = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True, help_text="Original price for discount display")
stock = models.IntegerField(default=0)
available = models.BooleanField(default=True)
featured = models.BooleanField(default=False)
image = models.ImageField(upload_to='products/')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
def get_discount_percent(self):
if self.compare_price and self.compare_price > self.price:
return int(((self.compare_price - self.price) / self.compare_price) * 100)
return 0
@property
def in_stock(self):
return self.stock > 0
class ProductVariation(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='variations')
size = models.CharField(max_length=20, blank=True)
color = models.CharField(max_length=50, blank=True)
stock = models.IntegerField(default=0)
price_adjustment = models.DecimalField(max_digits=10, decimal_places=2, default=0)
def __str__(self):
return f"{self.product.name} - {self.size} {self.color}"
class Cart(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, null=True, blank=True)
session_key = models.CharField(max_length=40, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"Cart {self.id}"
@property
def total(self):
return sum(item.total for item in self.items.all())
@property
def item_count(self):
return sum(item.quantity for item in self.items.all())
class CartItem(models.Model):
cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name='items')
product = models.ForeignKey(Product, on_delete=models.CASCADE)
variation = models.ForeignKey(ProductVariation, on_delete=models.SET_NULL, null=True, blank=True)
quantity = models.IntegerField(default=1, validators=[MinValueValidator(1)])
@property
def total(self):
return self.product.price * self.quantity
class Order(models.Model):
STATUS_CHOICES = [
('pending', 'Pending'),
('processing', 'Processing'),
('paid', 'Paid'),
('shipped', 'Shipped'),
('delivered', 'Delivered'),
('cancelled', 'Cancelled'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='orders')
order_number = models.CharField(max_length=20, unique=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
# Shipping information
full_name = models.CharField(max_length=200)
email = models.EmailField()
phone = models.CharField(max_length=20)
address = models.TextField()
city = models.CharField(max_length=100)
postal_code = models.CharField(max_length=20)
# Order totals
subtotal = models.DecimalField(max_digits=10, decimal_places=2)
discount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
shipping_cost = models.DecimalField(max_digits=10, decimal_places=2, default=0)
total = models.DecimalField(max_digits=10, decimal_places=2)
# Payment
payment_method = models.CharField(max_length=50)
payment_id = models.CharField(max_length=200, blank=True)
paid_at = models.DateTimeField(null=True, blank=True)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def save(self, *args, **kwargs):
if not self.order_number:
import random
import string
self.order_number = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10))
super().save(*args, **kwargs)
def __str__(self):
return f"Order {self.order_number}"
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items')
product = models.ForeignKey(Product, on_delete=models.CASCADE)
product_name = models.CharField(max_length=200) # Snapshot of product name
product_price = models.DecimalField(max_digits=10, decimal_places=2) # Snapshot of price
quantity = models.IntegerField()
variation = models.CharField(max_length=100, blank=True)
class Coupon(models.Model):
code = models.CharField(max_length=50, unique=True)
discount_type = models.CharField(max_length=20, choices=[('percentage', 'Percentage'), ('fixed', 'Fixed Amount')])
discount_value = models.DecimalField(max_digits=10, decimal_places=2)
minimum_order = models.DecimalField(max_digits=10, decimal_places=2, default=0)
valid_from = models.DateTimeField()
valid_to = models.DateTimeField()
usage_limit = models.IntegerField(default=1)
used_count = models.IntegerField(default=0)
active = models.BooleanField(default=True)
def is_valid(self):
from django.utils import timezone
return (self.active and
self.valid_from <= timezone.now() <= self.valid_to and
self.used_count < self.usage_limit)
# ========== CART VIEWS (store/views_cart.py) ==========
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.contrib import messages
from .models import Cart, CartItem, Product, Coupon
def get_or_create_cart(request):
"""Get existing cart or create new one"""
if request.user.is_authenticated:
cart, created = Cart.objects.get_or_create(user=request.user)
else:
session_key = request.session.session_key
if not session_key:
request.session.create()
session_key = request.session.session_key
cart, created = Cart.objects.get_or_create(session_key=session_key)
return cart
def add_to_cart(request, product_id):
"""Add product to cart (AJAX supported)"""
cart = get_or_create_cart(request)
product = get_object_or_404(Product, id=product_id, available=True)
variation_id = request.POST.get('variation')
quantity = int(request.POST.get('quantity', 1))
# Check if item already exists in cart
cart_item = CartItem.objects.filter(cart=cart, product=product).first()
if cart_item:
cart_item.quantity += quantity
cart_item.save()
else:
CartItem.objects.create(
cart=cart,
product=product,
quantity=quantity
)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({
'success': True,
'cart_count': cart.item_count,
'cart_total': float(cart.total)
})
messages.success(request, f'{product.name} added to cart!')
return redirect('store:cart_detail')
def cart_detail(request):
"""Display cart contents"""
cart = get_or_create_cart(request)
# Apply coupon if present
coupon_code = request.session.get('coupon_code')
discount = 0
if coupon_code:
try:
coupon = Coupon.objects.get(code=coupon_code, active=True)
if coupon.is_valid() and cart.total >= coupon.minimum_order:
if coupon.discount_type == 'percentage':
discount = cart.total * (coupon.discount_value / 100)
else:
discount = coupon.discount_value
except Coupon.DoesNotExist:
del request.session['coupon_code']
context = {
'cart': cart,
'cart_items': cart.items.all().select_related('product'),
'subtotal': cart.total,
'discount': discount,
'total': cart.total - discount,
}
return render(request, 'store/cart.html', context)
def update_cart(request, item_id):
"""Update cart item quantity"""
if request.method == 'POST':
cart_item = get_object_or_404(CartItem, id=item_id)
quantity = int(request.POST.get('quantity', 1))
if quantity > 0:
cart_item.quantity = quantity
cart_item.save()
else:
cart_item.delete()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
cart = cart_item.cart
return JsonResponse({
'success': True,
'item_total': float(cart_item.total),
'cart_total': float(cart.total),
'item_count': cart.item_count
})
return redirect('store:cart_detail')
def remove_from_cart(request, item_id):
"""Remove item from cart"""
cart_item = get_object_or_404(CartItem, id=item_id)
cart_item.delete()
messages.success(request, 'Item removed from cart.')
return redirect('store:cart_detail')
def apply_coupon(request):
"""Apply discount coupon"""
if request.method == 'POST':
coupon_code = request.POST.get('coupon_code')
try:
coupon = Coupon.objects.get(code=coupon_code, active=True)
cart = get_or_create_cart(request)
if coupon.is_valid() and cart.total >= coupon.minimum_order:
request.session['coupon_code'] = coupon_code
messages.success(request, f'Coupon {coupon_code} applied!')
else:
messages.error(request, 'Coupon is invalid or expired.')
except Coupon.DoesNotExist:
messages.error(request, 'Invalid coupon code.')
return redirect('store:cart_detail')
# ========== STRIPE PAYMENT INTEGRATION ==========
# Install: pip install stripe
import stripe
from django.conf import settings
from django.views.decorators.csrf import csrf_exempt
stripe.api_key = settings.STRIPE_SECRET_KEY
@login_required
def checkout(request):
"""Checkout page"""
cart = get_or_create_cart(request)
if cart.item_count == 0:
messages.warning(request, 'Your cart is empty.')
return redirect('store:cart_detail')
if request.method == 'POST':
# Create Stripe payment intent
intent = stripe.PaymentIntent.create(
amount=int(cart.total * 100), # Stripe uses cents
currency='usd',
metadata={'cart_id': cart.id, 'user_id': request.user.id}
)
return render(request, 'store/checkout.html', {
'client_secret': intent.client_secret,
'stripe_public_key': settings.STRIPE_PUBLIC_KEY,
'cart': cart
})
return render(request, 'store/checkout.html', {'cart': cart})
@csrf_exempt
def stripe_webhook(request):
"""Handle Stripe webhook for payment confirmation"""
payload = request.body
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except ValueError:
return HttpResponse(status=400)
except stripe.error.SignatureVerificationError:
return HttpResponse(status=400)
if event['type'] == 'payment_intent.succeeded':
intent = event['data']['object']
# Create order from cart
cart_id = intent['metadata']['cart_id']
cart = Cart.objects.get(id=cart_id)
order = Order.objects.create(
user=request.user,
status='paid',
subtotal=cart.total,
total=cart.total,
# ... other fields
)
# Move cart items to order items
for item in cart.items.all():
OrderItem.objects.create(
order=order,
product=item.product,
product_name=item.product.name,
product_price=item.product.price,
quantity=item.quantity
)
# Reduce stock
item.product.stock -= item.quantity
item.product.save()
# Clear cart
cart.items.all().delete()
return HttpResponse(status=200)
19.4 Project 4: REST API for a Task Manager with DRF (Intermediate)
Build a complete API that can serve a React or mobile frontend.
# ========== PROJECT 4: TASK MANAGER API ==========
# What you'll learn:
# - Django REST Framework setup
# - Token authentication
# - Nested serializers
# - Filtering, searching, pagination
# - API documentation
# ========== SERIALIZERS (api/serializers.py) ==========
from rest_framework import serializers
from django.contrib.auth.models import User
from todo.models import Task, Category
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'first_name', 'last_name']
class TaskSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
is_overdue = serializers.SerializerMethodField()
class Meta:
model = Task
fields = ['id', 'title', 'description', 'completed', 'priority',
'due_date', 'created_at', 'user', 'is_overdue']
read_only_fields = ['id', 'created_at', 'user']
def get_is_overdue(self, obj):
return obj.is_overdue()
def create(self, validated_data):
request = self.context.get('request')
validated_data['user'] = request.user
return super().create(validated_data)
class TaskCreateSerializer(serializers.ModelSerializer):
class Meta:
model = Task
fields = ['title', 'description', 'priority', 'due_date']
# ========== VIEWS (api/views.py) ==========
from rest_framework import viewsets, status, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework.authentication import TokenAuthentication
from django_filters.rest_framework import DjangoFilterBackend
from .models import Task
from .serializers import TaskSerializer, TaskCreateSerializer
class TaskViewSet(viewsets.ModelViewSet):
serializer_class = TaskSerializer
permission_classes = [IsAuthenticated]
authentication_classes = [TokenAuthentication]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['completed', 'priority']
search_fields = ['title', 'description']
ordering_fields = ['created_at', 'due_date', 'priority']
ordering = ['-created_at']
def get_queryset(self):
return Task.objects.filter(user=self.request.user)
def get_serializer_class(self):
if self.action == 'create':
return TaskCreateSerializer
return TaskSerializer
@action(detail=True, methods=['post'])
def toggle_complete(self, request, pk=None):
task = self.get_object()
task.completed = not task.completed
task.save()
return Response({'status': 'toggled', 'completed': task.completed})
@action(detail=False, methods=['get'])
def stats(self, request):
tasks = self.get_queryset()
stats = {
'total': tasks.count(),
'completed': tasks.filter(completed=True).count(),
'pending': tasks.filter(completed=False).count(),
'overdue': tasks.filter(completed=False, due_date__lt=timezone.now()).count(),
}
return Response(stats)
@action(detail=False, methods=['post'])
def bulk_delete(self, request):
task_ids = request.data.get('task_ids', [])
deleted = Task.objects.filter(id__in=task_ids, user=request.user).delete()
return Response({'deleted': deleted[0]})
# ========== URLS (api/urls.py) ==========
from rest_framework.routers import DefaultRouter
from django.urls import path, include
from . import views
router = DefaultRouter()
router.register(r'tasks', views.TaskViewSet)
urlpatterns = [
path('', include(router.urls)),
path('auth/', include('rest_framework.urls')),
]
# ========== API TESTING ==========
# Get token: POST /api/token/ with username/password
# Use token: Authorization: Token your_token_here
# List tasks: GET /api/tasks/
# Create task: POST /api/tasks/
# Toggle task: POST /api/tasks/1/toggle_complete/
# Get stats: GET /api/tasks/stats/
19.5 Project 5: Real-time Chat App with Django Channels (Advanced)
WebSockets take Django beyond HTTP. Build a real-time chat application.
# ========== PROJECT 5: REAL-TIME CHAT ==========
# Install: pip install channels channels-redis
# ========== SETTINGS (settings.py) ==========
INSTALLED_APPS = [
'channels',
'chat',
]
ASGI_APPLICATION = 'myproject.asgi.application'
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
'hosts': [('127.0.0.1', 6379)],
},
},
}
# ========== MODELS (chat/models.py) ==========
from django.db import models
from django.contrib.auth.models import User
class Room(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
participants = models.ManyToManyField(User, related_name='chat_rooms')
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class Message(models.Model):
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name='messages')
user = models.ForeignKey(User, on_delete=models.CASCADE)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
# ========== CONSUMER (chat/consumers.py) ==========
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from .models import Room, Message
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.room_slug = self.scope['url_route']['kwargs']['room_slug']
self.room_group_name = f'chat_{self.room_slug}'
# Join room group
await self.channel_layer.group_add(
self.room_group_name,
self.channel_name
)
await self.accept()
async def disconnect(self, close_code):
# Leave room group
await self.channel_layer.group_discard(
self.room_group_name,
self.channel_name
)
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message = text_data_json['message']
username = self.scope['user'].username
# Save message to database
await self.save_message(username, message)
# Send message to room group
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'chat_message',
'message': message,
'username': username,
'timestamp': str(timezone.now())
}
)
async def chat_message(self, event):
# Send message to WebSocket
await self.send(text_data=json.dumps({
'message': event['message'],
'username': event['username'],
'timestamp': event['timestamp']
}))
@database_sync_to_async
def save_message(self, username, message):
from django.contrib.auth.models import User
user = User.objects.get(username=username)
room = Room.objects.get(slug=self.room_slug)
Message.objects.create(room=room, user=user, content=message)
# ========== ROUTING (chat/routing.py) ==========
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r'ws/chat/(?P[^/]+)/$', consumers.ChatConsumer.as_asgi()),
]
# ========== ASGI (asgi.py) ==========
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from chat.routing import websocket_urlpatterns
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
application = ProtocolTypeRouter({
'http': get_asgi_application(),
'websocket': AuthMiddlewareStack(
URLRouter(websocket_urlpatterns)
),
})
# ========== TEMPLATE (chat/templates/chat/room.html) ==========
"""
<div id="chat-messages">
{% for message in messages %}
<div>
<strong>{{ message.user.username }}:</strong>
{{ message.content }}
<small>{{ message.created_at|timesince }} ago</small>
</div>
{% endfor %}
</div>
<input id="chat-input" type="text">
<button id="send-button">Send</button>
<script>
const chatSocket = new WebSocket(
'ws://' + window.location.host + '/ws/chat/{{ room.slug }}/'
);
chatSocket.onmessage = function(e) {
const data = JSON.parse(e.data);
const messageDiv = document.createElement('div');
messageDiv.innerHTML = `<strong>${data.username}:</strong> ${data.message}`;
document.getElementById('chat-messages').appendChild(messageDiv);
};
document.getElementById('send-button').onclick = function(e) {
const input = document.getElementById('chat-input');
chatSocket.send(JSON.stringify({
'message': input.value
}));
input.value = '';
};
</script>
"""
Project Completion Checklist
- ✅ Build Project 1 (Blog) - Understand Django fundamentals
- ✅ Build Project 2 (To-Do) - Master user-specific data and AJAX
- ✅ Build Project 3 (E-commerce) - Learn sessions, carts, and payments
- ✅ Build Project 4 (API) - Create REST APIs for modern frontends
- ✅ Build Project 5 (Chat) - Real-time features with WebSockets
Summary
Building these five projects will take you from beginner to job-ready. Each project builds on the previous one, adding new skills and concepts. Don't rush. Build each one completely. Break them. Fix them. Make them your own.
In the final chapter, we'll walk through a real-world case study - building a complete SaaS platform from requirements to deployment.