Chapter 19: Mini Projects for Hands-On Learning - IndianTechnoEra
Latest update Android YouTube

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.

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.

إرسال تعليق

Feel free to ask your query...
Cookie Consent
We serve cookies on this site to analyze traffic, remember your preferences, and optimize your experience.
Oops!
It seems there is something wrong with your internet connection. Please connect to the internet and start browsing again.
AdBlock Detected!
We have detected that you are using adblocking plugin in your browser.
The revenue we earn by the advertisements is used to manage this website, we request you to whitelist our website in your adblocking plugin.
Site is Blocked
Sorry! This site is not available in your country.