Final Project: Build a Complete Production Blog API
🎯 Project Overview
In this capstone project, you will build and deploy a complete Blog Platform API from scratch. This is not a tutorial where you copy code — this is a blueprint for a production-grade application that combines every module from this course into one cohesive system.
What you will build:
- A custom user system with email-based login and verification
- A rich blog schema with posts, categories, tags, and nested comments
- A fully RESTful API with filtering, search, and pagination
- JWT authentication with role-based access control
- Automated email notifications
- A comprehensive test suite
- Production deployment with Docker
Estimated time: 4–6 hours
Final deliverable: A live API URL serving JSON data to any frontend or mobile app
🏗️ Phase 1: Project Setup & Environment
1.1 Create the Project Structure
1mkdir django-blog-api && cd django-blog-api 2python -m venv venv 3source venv/bin/activate # Windows: venv\Scripts\activate 4 5pip install django djangorestframework djangorestframework-simplejwt \ 6 django-cors-headers django-filter python-decouple Pillow \ 7 psycopg2-binary whitenoise gunicorn django-redis 8 9pip freeze > requirements.txt 10 11django-admin startproject config . 12python manage.py startapp users 13python manage.py startapp blog 14python manage.py startapp api
1.2 Environment Configuration
Create .env in the project root:
1DJANGO_SECRET_KEY=change-me-in-production-very-long-random-string 2DJANGO_DEBUG=True 3DJANGO_SETTINGS_MODULE=config.settings.development 4 5DB_NAME=blogdb 6DB_USER=postgres 7DB_PASSWORD=postgres 8DB_HOST=localhost 9DB_PORT=5432 10 11SENDGRID_API_KEY=your-key-here
Create .gitignore:
1venv/ 2__pycache__/ 3db.sqlite3 4.env 5media/ 6staticfiles/ 7*.pyc 8.DS_Store
⚙️ Phase 2: Settings Architecture
Create a settings package instead of a single file:
config/settings/
__init__.py
base.py
development.py
production.py
base.py
1from pathlib import Path 2from decouple import config 3 4BASE_DIR = Path(__file__).resolve().parent.parent.parent 5 6SECRET_KEY = config('DJANGO_SECRET_KEY') 7DEBUG = config('DJANGO_DEBUG', default=False, cast=bool) 8 9ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='localhost,127.0.0.1', cast=lambda v: [s.strip() for s in v.split(',')]) 10 11INSTALLED_APPS = [ 12 'django.contrib.admin', 13 'django.contrib.auth', 14 'django.contrib.contenttypes', 15 'django.contrib.sessions', 16 'django.contrib.messages', 17 'django.contrib.staticfiles', 18 19 'rest_framework', 20 'rest_framework_simplejwt.token_blacklist', 21 'corsheaders', 22 'django_filters', 23 24 'users', 25 'blog', 26 'api', 27] 28 29MIDDLEWARE = [ 30 'corsheaders.middleware.CorsMiddleware', 31 'django.middleware.security.SecurityMiddleware', 32 'whitenoise.middleware.WhiteNoiseMiddleware', 33 'django.contrib.sessions.middleware.SessionMiddleware', 34 'django.middleware.common.CommonMiddleware', 35 'django.middleware.csrf.CsrfViewMiddleware', 36 'django.contrib.auth.middleware.AuthenticationMiddleware', 37 'django.contrib.messages.middleware.MessageMiddleware', 38 'django.middleware.clickjacking.XFrameOptionsMiddleware', 39] 40 41ROOT_URLCONF = 'config.urls' 42WSGI_APPLICATION = 'config.wsgi.application' 43 44DATABASES = { 45 'default': { 46 'ENGINE': 'django.db.backends.postgresql', 47 'NAME': config('DB_NAME'), 48 'USER': config('DB_USER'), 49 'PASSWORD': config('DB_PASSWORD'), 50 'HOST': config('DB_HOST'), 51 'PORT': config('DB_PORT', default='5432'), 52 'CONN_MAX_AGE': 600, 53 } 54} 55 56AUTH_PASSWORD_VALIDATORS = [ 57 {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, 58 {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': {'min_length': 8}}, 59 {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, 60 {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, 61] 62 63LANGUAGE_CODE = 'en-us' 64TIME_ZONE = 'UTC' 65USE_I18N = True 66USE_TZ = True 67 68STATIC_URL = 'static/' 69STATIC_ROOT = BASE_DIR / 'staticfiles' 70STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' 71 72MEDIA_URL = 'media/' 73MEDIA_ROOT = BASE_DIR / 'media' 74 75DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 76 77AUTH_USER_MODEL = 'users.User' 78 79REST_FRAMEWORK = { 80 'DEFAULT_AUTHENTICATION_CLASSES': [ 81 'rest_framework_simplejwt.authentication.JWTAuthentication', 82 ], 83 'DEFAULT_PERMISSION_CLASSES': [ 84 'rest_framework.permissions.IsAuthenticatedOrReadOnly', 85 ], 86 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 87 'PAGE_SIZE': 10, 88 'DEFAULT_FILTER_BACKENDS': [ 89 'django_filters.rest_framework.DjangoFilterBackend', 90 'rest_framework.filters.SearchFilter', 91 'rest_framework.filters.OrderingFilter', 92 ], 93} 94 95SIMPLE_JWT = { 96 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), 97 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), 98 'ROTATE_REFRESH_TOKENS': True, 99 'BLACKLIST_AFTER_ROTATION': True, 100} 101 102EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 103DEFAULT_FROM_EMAIL = 'noreply@blogapi.com'
development.py
1from .base import * 2EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
production.py
1from .base import * 2 3DEBUG = False 4SECURE_SSL_REDIRECT = True 5SESSION_COOKIE_SECURE = True 6CSRF_COOKIE_SECURE = True 7SECURE_HSTS_SECONDS = 31536000 8SECURE_HSTS_INCLUDE_SUBDOMAINS = True 9SECURE_HSTS_PRELOAD = True 10X_FRAME_OPTIONS = 'DENY' 11 12EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' 13EMAIL_HOST = 'smtp.sendgrid.net' 14EMAIL_PORT = 587 15EMAIL_USE_TLS = True 16EMAIL_HOST_USER = 'apikey' 17EMAIL_HOST_PASSWORD = config('SENDGRID_API_KEY')
👤 Phase 3: Custom User Model & Authentication
users/models.py
1from django.contrib.auth.models import AbstractUser 2from django.db import models 3 4 5class User(AbstractUser): 6 email = models.EmailField(unique=True) 7 is_verified = models.BooleanField(default=False) 8 9 ROLE_CHOICES = [ 10 ('reader', 'Reader'), 11 ('author', 'Author'), 12 ('moderator', 'Moderator'), 13 ('admin', 'Admin'), 14 ] 15 role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='reader') 16 bio = models.TextField(blank=True) 17 avatar = models.ImageField(upload_to='avatars/', blank=True, null=True) 18 19 USERNAME_FIELD = 'email' 20 REQUIRED_FIELDS = ['username', 'first_name', 'last_name'] 21 22 class Meta: 23 ordering = ['-date_joined'] 24 25 def __str__(self): 26 return self.email
users/admin.py
1from django.contrib import admin 2from django.contrib.auth.admin import UserAdmin 3from .models import User 4 5@admin.register(User) 6class CustomUserAdmin(UserAdmin): 7 list_display = ['email', 'username', 'first_name', 'role', 'is_verified', 'is_staff'] 8 list_filter = ['role', 'is_verified', 'is_staff', 'date_joined'] 9 fieldsets = UserAdmin.fieldsets + ( 10 ('Profile', {'fields': ('role', 'is_verified', 'bio', 'avatar')}), 11 ) 12 add_fieldsets = UserAdmin.add_fieldsets + ( 13 ('Profile', {'fields': ('role',)}), 14 )
🗄️ Phase 4: Blog Models & Relationships
blog/models.py
1from django.db import models 2from django.contrib.auth import get_user_model 3from django.utils.text import slugify 4 5User = get_user_model() 6 7 8class TimestampModel(models.Model): 9 created_at = models.DateTimeField(auto_now_add=True) 10 updated_at = models.DateTimeField(auto_now=True) 11 12 class Meta: 13 abstract = True 14 15 16class Category(TimestampModel): 17 name = models.CharField(max_length=100, unique=True) 18 slug = models.SlugField(max_length=100, unique=True) 19 description = models.TextField(blank=True) 20 21 class Meta: 22 ordering = ['name'] 23 verbose_name_plural = 'Categories' 24 25 def __str__(self): 26 return self.name 27 28 29class Tag(TimestampModel): 30 name = models.CharField(max_length=50, unique=True) 31 slug = models.SlugField(max_length=50, unique=True) 32 33 class Meta: 34 ordering = ['name'] 35 36 def __str__(self): 37 return self.name 38 39 40class Post(TimestampModel): 41 STATUS_CHOICES = [ 42 ('draft', 'Draft'), 43 ('published', 'Published'), 44 ('archived', 'Archived'), 45 ] 46 47 title = models.CharField(max_length=200) 48 slug = models.SlugField(max_length=200, unique=True) 49 author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts') 50 category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, related_name='posts') 51 tags = models.ManyToManyField(Tag, related_name='posts', blank=True) 52 content = models.TextField() 53 excerpt = models.TextField(max_length=500, blank=True) 54 featured_image = models.ImageField(upload_to='posts/%Y/%m/', blank=True, null=True) 55 status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='draft') 56 published_at = models.DateTimeField(null=True, blank=True) 57 view_count = models.PositiveIntegerField(default=0) 58 is_featured = models.BooleanField(default=False) 59 60 class Meta: 61 ordering = ['-created_at'] 62 indexes = [ 63 models.Index(fields=['-created_at']), 64 models.Index(fields=['status', '-published_at']), 65 ] 66 67 def __str__(self): 68 return self.title 69 70 def save(self, *args, **kwargs): 71 if not self.slug: 72 self.slug = slugify(self.title) 73 super().save(*args, **kwargs) 74 75 @property 76 def reading_time(self): 77 words = len(self.content.split()) 78 return max(1, round(words / 200)) 79 80 81class Comment(TimestampModel): 82 post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments') 83 author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='comments') 84 parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='replies') 85 content = models.TextField() 86 is_approved = models.BooleanField(default=True) 87 88 class Meta: 89 ordering = ['created_at'] 90 91 def __str__(self): 92 return f'Comment by {self.author.username}' 93 94 95class Like(models.Model): 96 user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='likes') 97 post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='likes') 98 created_at = models.DateTimeField(auto_now_add=True) 99 100 class Meta: 101 unique_together = ['user', 'post'] 102 103 def __str__(self): 104 return f'{self.user.email} likes {self.post.title}'
Run migrations:
1python manage.py makemigrations 2python manage.py migrate 3python manage.py createsuperuser
📡 Phase 5: Serializers
api/serializers.py
1from rest_framework import serializers 2from django.contrib.auth import get_user_model 3from django.utils.text import slugify 4from blog.models import Post, Category, Tag, Comment, Like 5 6User = get_user_model() 7 8 9class UserSerializer(serializers.ModelSerializer): 10 full_name = serializers.CharField(source='get_full_name', read_only=True) 11 post_count = serializers.IntegerField(source='posts.count', read_only=True) 12 13 class Meta: 14 model = User 15 fields = ['id', 'email', 'username', 'first_name', 'last_name', 'full_name', 'role', 'is_verified', 'bio', 'avatar', 'post_count', 'date_joined'] 16 read_only_fields = ['id', 'is_verified', 'date_joined'] 17 18 19class UserRegistrationSerializer(serializers.ModelSerializer): 20 password = serializers.CharField(write_only=True, min_length=8, style={'input_type': 'password'}) 21 password_confirm = serializers.CharField(write_only=True, style={'input_type': 'password'}) 22 23 class Meta: 24 model = User 25 fields = ['username', 'email', 'first_name', 'last_name', 'password', 'password_confirm'] 26 27 def validate(self, data): 28 if data['password'] != data['password_confirm']: 29 raise serializers.ValidationError({'password_confirm': 'Passwords do not match.'}) 30 return data 31 32 def create(self, validated_data): 33 validated_data.pop('password_confirm') 34 return User.objects.create_user(**validated_data) 35 36 37class CategorySerializer(serializers.ModelSerializer): 38 post_count = serializers.IntegerField(source='posts.count', read_only=True) 39 40 class Meta: 41 model = Category 42 fields = ['id', 'name', 'slug', 'description', 'post_count'] 43 44 45class TagSerializer(serializers.ModelSerializer): 46 class Meta: 47 model = Tag 48 fields = ['id', 'name', 'slug'] 49 50 51class CommentSerializer(serializers.ModelSerializer): 52 author = UserSerializer(read_only=True) 53 replies_count = serializers.IntegerField(source='replies.count', read_only=True) 54 55 class Meta: 56 model = Comment 57 fields = ['id', 'post', 'author', 'parent', 'content', 'is_approved', 'replies_count', 'created_at'] 58 read_only_fields = ['is_approved', 'created_at'] 59 60 def validate_parent(self, value): 61 if value and value.parent: 62 raise serializers.ValidationError('Nested replies beyond one level are not allowed.') 63 return value 64 65 66class PostListSerializer(serializers.ModelSerializer): 67 author = UserSerializer(read_only=True) 68 category = CategorySerializer(read_only=True) 69 tag_list = TagSerializer(source='tags', many=True, read_only=True) 70 comment_count = serializers.IntegerField(source='comments.count', read_only=True) 71 like_count = serializers.IntegerField(source='likes.count', read_only=True) 72 73 class Meta: 74 model = Post 75 fields = ['id', 'title', 'slug', 'author', 'category', 'tag_list', 'excerpt', 'featured_image', 'status', 'published_at', 'view_count', 'comment_count', 'like_count', 'reading_time', 'created_at'] 76 77 78class PostDetailSerializer(serializers.ModelSerializer): 79 author = UserSerializer(read_only=True) 80 category = CategorySerializer(read_only=True) 81 tags = TagSerializer(many=True, read_only=True) 82 comments = CommentSerializer(many=True, read_only=True) 83 is_author = serializers.SerializerMethodField() 84 is_liked = serializers.SerializerMethodField() 85 86 class Meta: 87 model = Post 88 fields = '__all__' 89 read_only_fields = ['slug', 'view_count', 'created_at', 'updated_at'] 90 91 def get_is_author(self, obj): 92 request = self.context.get('request') 93 if request and request.user.is_authenticated: 94 return obj.author == request.user 95 return False 96 97 def get_is_liked(self, obj): 98 request = self.context.get('request') 99 if request and request.user.is_authenticated: 100 return obj.likes.filter(user=request.user).exists() 101 return False 102 103 104class PostCreateUpdateSerializer(serializers.ModelSerializer): 105 tags = serializers.ListField(child=serializers.CharField(max_length=50), write_only=True, required=False) 106 107 class Meta: 108 model = Post 109 fields = ['title', 'content', 'category', 'tags', 'featured_image', 'status', 'excerpt'] 110 111 def create(self, validated_data): 112 tags_data = validated_data.pop('tags', []) 113 validated_data['slug'] = slugify(validated_data['title']) 114 post = Post.objects.create(**validated_data) 115 for tag_name in tags_data: 116 tag, _ = Tag.objects.get_or_create(name=tag_name.lower().strip(), defaults={'slug': slugify(tag_name)}) 117 post.tags.add(tag) 118 return post 119 120 def update(self, instance, validated_data): 121 tags_data = validated_data.pop('tags', None) 122 for attr, value in validated_data.items(): 123 setattr(instance, attr, value) 124 instance.save() 125 if tags_data is not None: 126 instance.tags.clear() 127 for tag_name in tags_data: 128 tag, _ = Tag.objects.get_or_create(name=tag_name.lower().strip(), defaults={'slug': slugify(tag_name)}) 129 instance.tags.add(tag) 130 return instance
🛡️ Phase 6: Permissions
api/permissions.py
1from rest_framework import permissions 2 3 4class IsAuthorOrReadOnly(permissions.BasePermission): 5 def has_object_permission(self, request, view, obj): 6 if request.method in permissions.SAFE_METHODS: 7 return True 8 return obj.author == request.user or request.user.is_staff 9 10 11class IsAdminOrReadOnly(permissions.BasePermission): 12 def has_permission(self, request, view): 13 if request.method in permissions.SAFE_METHODS: 14 return True 15 return request.user and request.user.is_staff 16 17 18class IsModeratorOrAbove(permissions.BasePermission): 19 def has_permission(self, request, view): 20 if not request.user or not request.user.is_authenticated: 21 return False 22 return request.user.role in ['moderator', 'admin'] or request.user.is_staff 23 24 25class IsVerifiedUser(permissions.BasePermission): 26 def has_permission(self, request, view): 27 return request.user and request.user.is_authenticated and request.user.is_verified
🌐 Phase 7: API Views & ViewSets
api/views.py
1from rest_framework import generics, viewsets, status, filters 2from rest_framework.decorators import action 3from rest_framework.response import Response 4from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly, AllowAny 5from rest_framework.views import APIView 6from rest_framework_simplejwt.tokens import RefreshToken 7from django.contrib.auth import authenticate 8from django.core.mail import send_mail 9from django.contrib.auth.tokens import default_token_generator 10from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode 11from django.utils.encoding import force_bytes, force_str 12from django.urls import reverse 13from django.conf import settings 14from django.db.models import Count, F 15from django_filters.rest_framework import DjangoFilterBackend 16from django.shortcuts import get_object_or_404 17 18from blog.models import Post, Category, Comment, Like 19from .serializers import ( 20 PostListSerializer, PostDetailSerializer, PostCreateUpdateSerializer, 21 CategorySerializer, CommentSerializer, UserRegistrationSerializer, 22 UserSerializer 23) 24from .permissions import IsAuthorOrReadOnly, IsModeratorOrAbove 25from .pagination import StandardResultsSetPagination 26 27User = get_user_model() 28 29 30# ==================== AUTH VIEWS ==================== 31 32class RegisterView(generics.CreateAPIView): 33 serializer_class = UserRegistrationSerializer 34 permission_classes = [AllowAny] 35 36 def create(self, request, *args, **kwargs): 37 serializer = self.get_serializer(data=request.data) 38 serializer.is_valid(raise_exception=True) 39 user = serializer.save() 40 user.is_active = False 41 user.save() 42 43 token = default_token_generator.make_token(user) 44 uid = urlsafe_base64_encode(force_bytes(user.pk)) 45 verification_url = request.build_absolute_uri( 46 reverse('verify-email', kwargs={'uidb64': uid, 'token': token}) 47 ) 48 49 send_mail( 50 subject='Verify your email address', 51 message=f'Click to verify: {verification_url}', 52 from_email=settings.DEFAULT_FROM_EMAIL, 53 recipient_list=[user.email], 54 ) 55 56 return Response({'message': 'Registration successful. Check your email.'}, status=status.HTTP_201_CREATED) 57 58 59class VerifyEmailView(APIView): 60 permission_classes = [AllowAny] 61 62 def get(self, request, uidb64, token): 63 try: 64 uid = force_str(urlsafe_base64_decode(uidb64)) 65 user = User.objects.get(pk=uid) 66 except (TypeError, ValueError, OverflowError, User.DoesNotExist): 67 user = None 68 69 if user and default_token_generator.check_token(user, token): 70 user.is_active = True 71 user.is_verified = True 72 user.save() 73 return Response({'message': 'Email verified. You can now log in.'}) 74 return Response({'error': 'Invalid or expired link'}, status=status.HTTP_400_BAD_REQUEST) 75 76 77class LoginView(APIView): 78 permission_classes = [AllowAny] 79 80 def post(self, request): 81 email = request.data.get('email') 82 password = request.data.get('password') 83 user = authenticate(request, username=email, password=password) 84 85 if user: 86 refresh = RefreshToken.for_user(user) 87 return Response({ 88 'refresh': str(refresh), 89 'access': str(refresh.access_token), 90 'user': UserSerializer(user).data, 91 }) 92 return Response({'error': 'Invalid credentials'}, status=status.HTTP_401_UNAUTHORIZED) 93 94 95class LogoutView(APIView): 96 permission_classes = [IsAuthenticated] 97 98 def post(self, request): 99 try: 100 refresh_token = request.data.get('refresh_token') 101 token = RefreshToken(refresh_token) 102 token.blacklist() 103 return Response({'message': 'Logged out successfully'}) 104 except Exception as e: 105 return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) 106 107 108# ==================== POST VIEWS ==================== 109 110class PostListCreateView(generics.ListCreateAPIView): 111 queryset = Post.objects.filter(status='published') 112 pagination_class = StandardResultsSetPagination 113 filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] 114 filterset_fields = ['category', 'author', 'status', 'is_featured'] 115 search_fields = ['title', 'content', 'excerpt'] 116 ordering_fields = ['created_at', 'view_count', 'published_at'] 117 ordering = ['-created_at'] 118 119 def get_serializer_class(self): 120 if self.request.method == 'POST': 121 return PostCreateUpdateSerializer 122 return PostListSerializer 123 124 def get_permissions(self): 125 if self.request.method == 'POST': 126 return [IsAuthenticated()] 127 return [IsAuthenticatedOrReadOnly()] 128 129 def perform_create(self, serializer): 130 serializer.save(author=self.request.user) 131 132 133class PostRetrieveUpdateDestroyView(generics.RetrieveUpdateDestroyAPIView): 134 queryset = Post.objects.all() 135 lookup_field = 'slug' 136 permission_classes = [IsAuthorOrReadOnly] 137 138 def get_serializer_class(self): 139 if self.request.method in ['PUT', 'PATCH']: 140 return PostCreateUpdateSerializer 141 return PostDetailSerializer 142 143 def retrieve(self, request, *args, **kwargs): 144 instance = self.get_object() 145 Post.objects.filter(pk=instance.pk).update(view_count=F('view_count') + 1) 146 instance.refresh_from_db() 147 serializer = self.get_serializer(instance) 148 return Response(serializer.data) 149 150 151# ==================== POST VIEWSET WITH ACTIONS ==================== 152 153class PostViewSet(viewsets.ModelViewSet): 154 queryset = Post.objects.filter(status='published') 155 serializer_class = PostListSerializer 156 lookup_field = 'slug' 157 permission_classes = [IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly] 158 159 @action(detail=True, methods=['post'], permission_classes=[IsAuthenticated]) 160 def like(self, request, slug=None): 161 post = self.get_object() 162 like, created = Like.objects.get_or_create(user=request.user, post=post) 163 if not created: 164 return Response({'detail': 'Already liked.'}, status=status.HTTP_400_BAD_REQUEST) 165 return Response({'detail': 'Post liked.', 'like_count': post.likes.count()}, status=status.HTTP_201_CREATED) 166 167 @action(detail=True, methods=['post'], permission_classes=[IsAuthenticated]) 168 def unlike(self, request, slug=None): 169 post = self.get_object() 170 deleted, _ = Like.objects.filter(user=request.user, post=post).delete() 171 if deleted == 0: 172 return Response({'detail': 'Not liked yet.'}, status=status.HTTP_400_BAD_REQUEST) 173 return Response({'detail': 'Post unliked.', 'like_count': post.likes.count()}) 174 175 176# ==================== COMMENT VIEWSET ==================== 177 178class CommentViewSet(viewsets.ModelViewSet): 179 serializer_class = CommentSerializer 180 permission_classes = [IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly] 181 182 def get_queryset(self): 183 post_slug = self.kwargs.get('post_slug') 184 if post_slug: 185 return Comment.objects.filter(post__slug=post_slug, is_approved=True) 186 return Comment.objects.filter(author=self.request.user) 187 188 def perform_create(self, serializer): 189 post = get_object_or_404(Post, slug=self.kwargs.get('post_slug')) 190 serializer.save(author=self.request.user, post=post) 191 192 @action(detail=True, methods=['post'], permission_classes=[IsModeratorOrAbove]) 193 def approve(self, request, pk=None): 194 comment = self.get_object() 195 comment.is_approved = True 196 comment.save() 197 return Response({'status': 'comment approved'}) 198 199 200# ==================== CATEGORY VIEW ==================== 201 202@api_view(['GET', 'POST']) 203@permission_classes([IsAuthenticatedOrReadOnly]) 204def category_list(request): 205 if request.method == 'GET': 206 categories = Category.objects.annotate(post_count=Count('posts')) 207 serializer = CategorySerializer(categories, many=True) 208 return Response(serializer.data) 209 210 if request.method == 'POST': 211 if not request.user.is_staff: 212 return Response({'error': 'Admin only'}, status=403) 213 serializer = CategorySerializer(data=request.data) 214 if serializer.is_valid(): 215 serializer.save() 216 return Response(serializer.data, status=201) 217 return Response(serializer.errors, status=400)
api/pagination.py
1from rest_framework.pagination import PageNumberPagination 2 3class StandardResultsSetPagination(PageNumberPagination): 4 page_size = 10 5 page_size_query_param = 'page_size' 6 max_page_size = 100
🔗 Phase 8: URL Routing
api/urls.py
1from django.urls import path, include 2from rest_framework.routers import DefaultRouter 3from . import views 4 5router = DefaultRouter() 6router.register(r'posts', views.PostViewSet, basename='post') 7router.register(r'comments', views.CommentViewSet, basename='comment') 8 9urlpatterns = [ 10 path('', include(router.urls)), 11 path('posts/<slug:post_slug>/comments/', views.CommentViewSet.as_view({'get': 'list', 'post': 'create'})), 12 path('categories/', views.category_list, name='api-categories'), 13 path('auth/register/', views.RegisterView.as_view(), name='api-register'), 14 path('auth/verify/<uidb64>/<token>/', views.VerifyEmailView.as_view(), name='verify-email'), 15 path('auth/login/', views.LoginView.as_view(), name='api-login'), 16 path('auth/logout/', views.LogoutView.as_view(), name='api-logout'), 17 path('posts-list/', views.PostListCreateView.as_view(), name='api-posts'), 18 path('posts-list/<slug:slug>/', views.PostRetrieveUpdateDestroyView.as_view(), name='api-post-detail'), 19]
config/urls.py
1from django.contrib import admin 2from django.urls import path, include 3from django.conf import settings 4from django.conf.urls.static import static 5 6urlpatterns = [ 7 path('admin/', admin.site.urls), 8 path('api/', include('api.urls')), 9] 10 11if settings.DEBUG: 12 urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
🧪 Phase 9: Comprehensive Tests
blog/tests.py
1from django.test import TestCase 2from django.contrib.auth import get_user_model 3from .models import Category, Post, Comment 4 5User = get_user_model() 6 7 8class PostModelTest(TestCase): 9 def setUp(self): 10 self.user = User.objects.create_user( 11 username='testuser', email='test@test.com', password='pass123' 12 ) 13 self.category = Category.objects.create(name='Tech', slug='tech') 14 self.post = Post.objects.create( 15 title='Test Post', slug='test-post', author=self.user, 16 category=self.category, content='Test content here.', status='published' 17 ) 18 19 def test_post_creation(self): 20 self.assertEqual(self.post.title, 'Test Post') 21 self.assertEqual(str(self.post), 'Test Post') 22 23 def test_reading_time(self): 24 self.assertEqual(self.post.reading_time, 1) 25 26 def test_slug_auto_generation(self): 27 post = Post.objects.create(title='Another Post', author=self.user, content='Content') 28 self.assertEqual(post.slug, 'another-post')
api/tests.py
1from django.urls import reverse 2from rest_framework.test import APITestCase, APIClient 3from rest_framework import status 4from django.contrib.auth import get_user_model 5from blog.models import Post, Category, Comment 6 7User = get_user_model() 8 9 10class PostAPITest(APITestCase): 11 def setUp(self): 12 self.client = APIClient() 13 self.user = User.objects.create_user( 14 username='apiuser', email='api@test.com', password='testpass123' 15 ) 16 self.category = Category.objects.create(name='API', slug='api') 17 self.post = Post.objects.create( 18 title='API Post', slug='api-post', author=self.user, 19 category=self.category, content='API test content', status='published' 20 ) 21 22 def test_list_posts(self): 23 url = reverse('api-posts') 24 response = self.client.get(url) 25 self.assertEqual(response.status_code, status.HTTP_200_OK) 26 self.assertEqual(len(response.data['results']), 1) 27 28 def test_create_post_authenticated(self): 29 self.client.force_authenticate(user=self.user) 30 url = reverse('api-posts') 31 response = self.client.post(url, { 32 'title': 'New Post', 'content': 'New content', 'category': self.category.id 33 }) 34 self.assertEqual(response.status_code, status.HTTP_201_CREATED) 35 36 def test_like_post(self): 37 self.client.force_authenticate(user=self.user) 38 url = reverse('post-like', kwargs={'slug': self.post.slug}) 39 response = self.client.post(url) 40 self.assertEqual(response.status_code, status.HTTP_201_CREATED) 41 self.assertEqual(response.data['like_count'], 1) 42 43 44class AuthTest(APITestCase): 45 def test_registration(self): 46 url = reverse('api-register') 47 response = self.client.post(url, { 48 'username': 'newbie', 49 'email': 'new@example.com', 50 'first_name': 'New', 51 'last_name': 'User', 52 'password': 'securepass123', 53 'password_confirm': 'securepass123' 54 }) 55 self.assertEqual(response.status_code, status.HTTP_201_CREATED) 56 self.assertEqual(User.objects.count(), 1) 57 58 def test_login(self): 59 User.objects.create_user(username='logintest', email='login@test.com', password='mypass123') 60 url = reverse('api-login') 61 response = self.client.post(url, {'email': 'login@test.com', 'password': 'mypass123'}) 62 self.assertEqual(response.status_code, status.HTTP_200_OK) 63 self.assertIn('access', response.data)
Run tests:
1python manage.py test 2coverage run --source='.' manage.py test 3coverage report
🐳 Phase 10: Production Deployment
Dockerfile
1FROM python:3.11-slim 2 3WORKDIR /app 4 5ENV PYTHONDONTWRITEBYTECODE=1 6ENV PYTHONUNBUFFERED=1 7ENV DJANGO_SETTINGS_MODULE=config.settings.production 8 9RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/* 10 11COPY requirements.txt . 12RUN pip install --no-cache-dir -r requirements.txt 13 14COPY . . 15 16RUN python manage.py collectstatic --noinput 17 18EXPOSE 8000 19 20CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "config.wsgi:application"]
gunicorn.conf.py
1bind = "0.0.0.0:8000" 2workers = 4 3worker_class = "sync" 4timeout = 30 5keepalive = 2 6errorlog = "-" 7accesslog = "-"
Deploy to Render
- Push code to GitHub
- Create new Web Service on Render → select Docker environment
- Create PostgreSQL database on Render
- Add environment variables:
DJANGO_SECRET_KEYDJANGO_DEBUG=FalseDJANGO_SETTINGS_MODULE=config.settings.productionDB_NAME,DB_USER,DB_PASSWORD,DB_HOST(from Render PostgreSQL)
- Deploy
✅ Final Project Checklist
| Feature | Status |
|---|---|
| Custom User model with email login | ✅ |
| Email verification on registration | ✅ |
| Post/Category/Tag/Comment models | ✅ |
| Nested comments (one level) | ✅ |
| Like/Unlike system | ✅ |
| REST API with serializers | ✅ |
| JWT authentication (access + refresh) | ✅ |
| Role-based permissions (Reader/Author/Moderator/Admin) | ✅ |
| Search, filter, ordering, pagination | ✅ |
| Object-level permissions (only author can edit) | ✅ |
| View increment on detail view | ✅ |
| Comprehensive tests (models + API + auth) | ✅ |
| Production settings with security headers | ✅ |
| Docker containerization | ✅ |
| Deployed to cloud | ✅ |
🚀 API Endpoints Reference
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/api/auth/register/ | POST | No | Register new user |
/api/auth/verify/<uid>/<token>/ | GET | No | Verify email |
/api/auth/login/ | POST | No | Login, get JWT |
/api/auth/logout/ | POST | Yes | Blacklist refresh token |
/api/posts/ | GET | No | List posts (paginated, filterable) |
/api/posts/ | POST | Yes | Create post |
/api/posts/<slug>/ | GET | No | Retrieve post |
/api/posts/<slug>/ | PUT/PATCH | Yes | Update post (author only) |
/api/posts/<slug>/ | DELETE | Yes | Delete post (author only) |
/api/posts/<slug>/like/ | POST | Yes | Like a post |
/api/posts/<slug>/unlike/ | POST | Yes | Unlike a post |
/api/posts/<slug>/comments/ | GET | No | List comments |
/api/posts/<slug>/comments/ | POST | Yes | Add comment |
/api/comments/<pk>/approve/ | POST | Mod+ | Approve comment |
/api/categories/ | GET | No | List categories |
/api/categories/ | POST | Admin | Create category |
🎓 You Did It!
You have built a complete, production-ready Blog API using Django and Django REST Framework. This project demonstrates:
- Professional architecture with separated apps and settings
- Secure authentication with JWT and email verification
- Complex database design with relationships and indexing
- RESTful API design with filtering, search, and pagination
- Role-based access control at both view and object levels
- Defensive programming with tests covering models, views, and auth
- Production deployment with Docker and security headers
This project belongs in your portfolio. Link to the live API, share the GitHub repository, and use it as the foundation for your next big idea.
Keep building. The web is yours. 🐍🌐🚀