π οΈ Weekend Project: Django
You've spent the week learning models, views, serializers, ViewSets, and routers in isolation. This weekend you'll wire them all together into DevExchange β a small but complete knowledge-sharing platform with a template-based web UI and a Django REST Framework API. It's a guided build: five milestones, a checklist to tick off, and a clear picture of what "good" looks like when you're done.
π― Learning Objectives
By the end of this build, you will be able to:
- Structure a multi-app Django 5 project and design related models (including generic relations for votes and comments)
- Serve the same data two ways β class-based template views and a DRF ViewSet API β from one codebase
- Enforce object-level permissions so users can only edit their own content
- Add filtering, search, and custom
@actionendpoints (vote, comment, accept answer) - Write API tests and judge your own work against a quality rubric
Estimated Time: 6β10 hours across a weekend β’ Difficulty: Intermediate
Hands-on: This entire lesson is the exercise β build DevExchange milestone by milestone.
In This Lesson
The Brief: What You're Building
DevExchange is a place where developers share useful links and ask each other questions β think a tiny mash-up of a link aggregator and a Q&A board. It's deliberately scoped so you can finish a working version in a weekend, but rich enough to exercise every Django concept from this module.
The point of a capstone like this isn't to invent something novel β it's to connect the pieces you've been learning separately. When a bug appears at the seam between a serializer and a view, that's exactly the muscle a weekend project builds.
π The feature set (your MVP scope)
Resources: users post a title, URL, and description; others browse, filter by tag, upvote, and comment.
Questions & Answers: users ask questions, post answers, and the question's author can mark one answer as accepted.
Votes & Comments: a single reusable mechanism attaches to any content type via Django's generic relations.
Two front doors: a server-rendered template UI at /resources/ and a JSON API at /api/resources/.
β οΈ Scope discipline
The fastest way to not finish a weekend project is to gold-plate it. Real-time updates, OAuth login, and Elasticsearch are listed as stretch goals at the end for a reason. Ship the MVP first; a working small thing beats a broken big thing every time.
The Milestone Roadmap
Rather than build everything at once, you'll ship in five vertical slices. Each milestone produces something you can actually run and verify before moving on β this keeps momentum high and debugging cheap.
Project & Models] --> M2[Milestone 2
API Layer] M2 --> M3[Milestone 3
Permissions & Actions] M3 --> M4[Milestone 4
Web Interface] M4 --> M5[Milestone 5
Tests & Polish]
Here is the data model you'll be implementing. Notice how Vote and Comment attach to resources, questions, and answers through a single generic relationship rather than three separate tables:
Vote/Comment mechanism serves three content types through a generic relation, instead of duplicating the table three times.Milestone 1 β Project & Models
Goal: a running Django 5 project with a clean app split and all models migrated. When this milestone is done you should be able to create objects in the Django admin.
Scaffold the project
# Create the project folder and an isolated environment
mkdir devexchange && cd devexchange
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Django 5 + the API/filter stack
pip install "django>=5.0" djangorestframework django-filter
pip freeze > requirements.txt
# One project, several focused apps
django-admin startproject devexchange .
python manage.py startapp core # shared: Profile, Tag, Vote, Comment
python manage.py startapp resources # Resource
python manage.py startapp questions # Question, Answer
python manage.py startapp api # serializers, viewsets, routes
Register everything in settings.py. Splitting by domain (core / resources / questions) keeps each app small and its models easy to reason about.
# devexchange/settings.py
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# third-party
"rest_framework",
"django_filters",
# local
"core",
"resources",
"questions",
"api",
]
REST_FRAMEWORK = {
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 10,
}
The shared core models
The Vote and Comment models use a GenericForeignKey so they can point at a resource, a question, or an answer without three near-identical tables. The unique_together on Vote guarantees one vote per user per object.
# core/models.py
from django.db import models
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.utils.text import slugify
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
bio = models.TextField(blank=True)
website = models.URLField(blank=True)
github_username = models.CharField(max_length=50, blank=True)
def __str__(self):
return f"{self.user.username}'s profile"
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(max_length=50, unique=True, 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 Vote(models.Model):
UPVOTE, DOWNVOTE = 1, -1
VOTE_CHOICES = [(UPVOTE, "Upvote"), (DOWNVOTE, "Downvote")]
user = models.ForeignKey(User, on_delete=models.CASCADE)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey("content_type", "object_id")
value = models.SmallIntegerField(choices=VOTE_CHOICES)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
# one vote per user per object
constraints = [
models.UniqueConstraint(
fields=["user", "content_type", "object_id"],
name="one_vote_per_user_per_object",
)
]
def __str__(self):
return f"{self.user.username} {self.get_value_display()}"
class Comment(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey("content_type", "object_id")
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created_at"]
def __str__(self):
return f"Comment by {self.user.username}"
The resource and question models
A small Votable abstract base keeps the vote/comment relations and the vote_score helper DRY across all three content models β an improvement over copy-pasting the same three lines everywhere.
# resources/models.py (questions/models.py follows the same pattern)
from django.db import models
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericRelation
from core.models import Tag, Vote, Comment
class Votable(models.Model):
"""Abstract mixin: anything that can be voted on and commented on."""
votes = GenericRelation(Vote)
comments = GenericRelation(Comment)
class Meta:
abstract = True
@property
def vote_score(self) -> int:
return self.votes.aggregate(models.Sum("value"))["value__sum"] or 0
class Resource(Votable):
title = models.CharField(max_length=200)
url = models.URLField()
description = models.TextField()
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="resources")
tags = models.ManyToManyField(Tag, related_name="resources", blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return self.title
π‘ Milestone 1 done whenβ¦
You've run python manage.py makemigrations && python manage.py migrate, registered the models in each app's admin.py, and created a couple of resources and questions through /admin/ without errors.
Milestone 2 β The API Layer
Goal: a browsable REST API at /api/ that lists and creates resources, questions, and answers. This is where serializers and ViewSets earn their keep.
Serializers
Note the tag_ids write-only field: clients send tag primary keys, but reads return the full nested tag objects. This is a common and clean way to handle many-to-many writes.
# api/serializers.py
from rest_framework import serializers
from django.contrib.auth.models import User
from core.models import Tag, Comment
from resources.models import Resource
from questions.models import Question, Answer
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "username", "email", "date_joined"]
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ["id", "name", "slug"]
class CommentSerializer(serializers.ModelSerializer):
author = UserSerializer(source="user", read_only=True)
class Meta:
model = Comment
fields = ["id", "author", "body", "created_at"]
read_only_fields = ["created_at"]
class ResourceSerializer(serializers.ModelSerializer):
author = UserSerializer(read_only=True)
tags = TagSerializer(many=True, read_only=True)
tag_ids = serializers.PrimaryKeyRelatedField(
queryset=Tag.objects.all(), many=True, write_only=True,
source="tags", required=False,
)
vote_score = serializers.IntegerField(read_only=True)
comments_count = serializers.IntegerField(source="comments.count", read_only=True)
class Meta:
model = Resource
fields = [
"id", "title", "url", "description", "created_at", "updated_at",
"author", "tags", "tag_ids", "vote_score", "comments_count",
]
read_only_fields = ["created_at", "updated_at"]
class AnswerSerializer(serializers.ModelSerializer):
author = UserSerializer(read_only=True)
vote_score = serializers.IntegerField(read_only=True)
class Meta:
model = Answer
fields = [
"id", "question", "body", "created_at", "updated_at",
"author", "is_accepted", "vote_score",
]
read_only_fields = ["created_at", "updated_at", "is_accepted"]
class QuestionSerializer(serializers.ModelSerializer):
author = UserSerializer(read_only=True)
tags = TagSerializer(many=True, read_only=True)
tag_ids = serializers.PrimaryKeyRelatedField(
queryset=Tag.objects.all(), many=True, write_only=True,
source="tags", required=False,
)
answers = AnswerSerializer(many=True, read_only=True)
vote_score = serializers.IntegerField(read_only=True)
class Meta:
model = Question
fields = [
"id", "title", "body", "created_at", "updated_at", "author",
"tags", "tag_ids", "answers", "vote_score",
]
read_only_fields = ["created_at", "updated_at"]
ViewSets and the router
Because DRF ships the model many-to-many .set() logic in its default create()/update(), using source="tags" means you no longer need the hand-written create() methods the old code had β one fewer place for bugs to hide.
# api/views.py
from rest_framework import viewsets, permissions, filters
from django_filters.rest_framework import DjangoFilterBackend
from resources.models import Resource
from questions.models import Question, Answer
from .serializers import ResourceSerializer, QuestionSerializer, AnswerSerializer
from .permissions import IsAuthorOrReadOnly
class ResourceViewSet(viewsets.ModelViewSet):
queryset = Resource.objects.select_related("author").prefetch_related("tags")
serializer_class = ResourceSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ["author__username", "tags__slug"]
search_fields = ["title", "description"]
ordering_fields = ["created_at", "title"]
def perform_create(self, serializer):
serializer.save(author=self.request.user)
# api/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register("resources", views.ResourceViewSet)
router.register("questions", views.QuestionViewSet)
router.register("answers", views.AnswerViewSet)
urlpatterns = [
path("", include(router.urls)),
path("auth/", include("rest_framework.urls")), # browsable-API login
]
# devexchange/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("api.urls")),
]
π‘ Milestone 2 done whenβ¦
Visiting http://localhost:8000/api/resources/ shows DRF's browsable API, you can page through results, and ?search=django and ?tags__slug=python narrow the list.
Milestone 3 β Permissions & Actions
Goal: lock down who can edit what, and add the custom endpoints that make DevExchange interactive β voting, commenting, and accepting an answer.
Object-level permission
# api/permissions.py
from rest_framework import permissions
class IsAuthorOrReadOnly(permissions.BasePermission):
"""Read for anyone; write only for the object's author."""
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.author == request.user
Custom actions
The vote logic is identical across resources, questions, and answers, so factor it into a reusable mixin rather than pasting it three times. update_or_create collapses the old "look it up, then branch" code into one call.
# api/mixins.py
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django.contrib.contenttypes.models import ContentType
from core.models import Vote, Comment
from .serializers import CommentSerializer
class VoteCommentMixin:
"""Adds /vote/ and /comment/ actions to any ModelViewSet."""
@action(detail=True, methods=["post"], permission_classes=[IsAuthenticated])
def vote(self, request, pk=None):
obj = self.get_object()
value = request.data.get("value")
if value not in (1, -1):
return Response(
{"detail": "value must be 1 or -1."},
status=status.HTTP_400_BAD_REQUEST,
)
ct = ContentType.objects.get_for_model(obj)
Vote.objects.update_or_create(
user=request.user, content_type=ct, object_id=obj.id,
defaults={"value": value},
)
return Response({"vote_score": obj.vote_score})
@action(detail=True, methods=["post"])
def comment(self, request, pk=None):
obj = self.get_object()
serializer = CommentSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
comment = Comment.objects.create(
user=request.user,
content_type=ContentType.objects.get_for_model(obj),
object_id=obj.id,
body=serializer.validated_data["body"],
)
return Response(CommentSerializer(comment).data, status=status.HTTP_201_CREATED)
The accept answer action is question-specific: only the question's author may accept, and accepting one answer clears any previously accepted one.
# api/views.py (AnswerViewSet)
from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from questions.models import Answer
from .serializers import AnswerSerializer
from .permissions import IsAuthorOrReadOnly
from .mixins import VoteCommentMixin
class AnswerViewSet(VoteCommentMixin, viewsets.ModelViewSet):
queryset = Answer.objects.select_related("author", "question")
serializer_class = AnswerSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly]
def perform_create(self, serializer):
serializer.save(author=self.request.user)
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
def accept(self, request, pk=None):
answer = self.get_object()
if request.user != answer.question.author:
return Response(
{"detail": "Only the question author can accept an answer."},
status=status.HTTP_403_FORBIDDEN,
)
answer.question.answers.update(is_accepted=False) # clear old
answer.is_accepted = True
answer.save(update_fields=["is_accepted"])
return Response(self.get_serializer(answer).data)
π‘ Milestone 3 done whenβ¦
An anonymous user gets 403 trying to edit a resource; the author succeeds; POST /api/resources/1/vote/ with {"value": 1} returns an updated vote_score; and only the question author can hit /accept/.
Milestone 4 β The Web Interface
Goal: a template-rendered UI so non-API users can browse and post. You'll reuse the exact same models β only the presentation layer changes. Django's generic class-based views do most of the work.
# resources/views.py
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.urls import reverse_lazy
from .models import Resource
class ResourceListView(ListView):
model = Resource
template_name = "resources/resource_list.html"
context_object_name = "resources"
paginate_by = 10
def get_queryset(self):
qs = super().get_queryset().select_related("author").prefetch_related("tags")
tag = self.request.GET.get("tag")
return qs.filter(tags__slug=tag) if tag else qs
class ResourceDetailView(DetailView):
model = Resource
template_name = "resources/resource_detail.html"
class ResourceCreateView(LoginRequiredMixin, CreateView):
model = Resource
fields = ["title", "url", "description", "tags"]
template_name = "resources/resource_form.html"
success_url = reverse_lazy("resource-list")
def form_valid(self, form):
form.instance.author = self.request.user
return super().form_valid(form)
class ResourceUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
model = Resource
fields = ["title", "url", "description", "tags"]
template_name = "resources/resource_form.html"
def test_func(self):
return self.request.user == self.get_object().author
A minimal template, extending a shared base.html. Keep it plain β this is a backend course, so the UI just needs to be clear and correct, not pixel-perfect.
<!-- templates/resources/resource_list.html -->
{% extends "base.html" %}
{% block content %}
<h1>Dev Resources</h1>
{% if user.is_authenticated %}
<a href="{% url 'resource-create' %}">+ Share a resource</a>
{% endif %}
{% for resource in resources %}
<article>
<h2><a href="{% url 'resource-detail' resource.pk %}">{{ resource.title }}</a></h2>
<p>{{ resource.description|truncatewords:40 }}</p>
<p>
{% for tag in resource.tags.all %}
<a href="?tag={{ tag.slug }}">#{{ tag.name }}</a>
{% endfor %}
— score {{ resource.vote_score }} · by {{ resource.author.username }}
</p>
</article>
{% empty %}
<p>No resources yet. Be the first to share one!</p>
{% endfor %}
{% if is_paginated %}
<nav>
{% if page_obj.has_previous %}<a href="?page={{ page_obj.previous_page_number }}">Previous</a>{% endif %}
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
{% if page_obj.has_next %}<a href="?page={{ page_obj.next_page_number }}">Next</a>{% endif %}
</nav>
{% endif %}
{% endblock %}
π‘ Milestone 4 done whenβ¦
/resources/ lists resources with working pagination and tag filtering, logged-in users can create one via a form, and non-authors are blocked from the edit view.
Milestone 5 β Tests & Polish
Goal: a test suite proving the important behaviours, plus a quick performance pass. Tests are what turn "it worked when I clicked around" into "it provably works."
# api/tests.py
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from django.contrib.auth.models import User
from resources.models import Resource
from core.models import Tag
class ResourceAPITests(APITestCase):
def setUp(self):
self.author = User.objects.create_user("alice", password="pw12345!")
self.other = User.objects.create_user("bob", password="pw12345!")
self.tag = Tag.objects.create(name="Django", slug="django")
self.resource = Resource.objects.create(
title="DRF docs", url="https://www.django-rest-framework.org/",
description="The official guide.", author=self.author,
)
def test_anyone_can_list(self):
res = self.client.get(reverse("resource-list"))
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(res.data["count"], 1)
def test_anonymous_cannot_create(self):
res = self.client.post(reverse("resource-list"), {
"title": "x", "url": "https://x.dev", "description": "y",
}, format="json")
self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED)
def test_author_can_create_with_tags(self):
self.client.force_authenticate(self.author)
res = self.client.post(reverse("resource-list"), {
"title": "New", "url": "https://x.dev", "description": "z",
"tag_ids": [self.tag.id],
}, format="json")
self.assertEqual(res.status_code, status.HTTP_201_CREATED)
self.assertEqual(Resource.objects.count(), 2)
def test_non_author_cannot_update(self):
self.client.force_authenticate(self.other)
res = self.client.patch(
reverse("resource-detail", args=[self.resource.id]),
{"title": "hijacked"}, format="json",
)
self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN)
def test_voting_updates_score(self):
self.client.force_authenticate(self.other)
res = self.client.post(
reverse("resource-vote", args=[self.resource.id]),
{"value": 1}, format="json",
)
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(res.data["vote_score"], 1)
Run the suite with python manage.py test. Then do a quick performance pass β the biggest win in a list-heavy app is eliminating N+1 queries with select_related (foreign keys) and prefetch_related (many-to-many and reverse relations), which the ViewSets above already use.
β οΈ Watch for the N+1 trap
Without prefetch_related("tags"), rendering a 10-item list issues one query per resource just to fetch its tags β 11 queries instead of 2. Install Django Debug Toolbar in development and watch the query count; if it scales with the number of rows, you have an N+1.
π‘ Milestone 5 done whenβ¦
python manage.py test passes green, and your list endpoints hold a constant, small query count regardless of how many rows they return.
Completion Checklist
Tick these off before you call DevExchange finished. Treat any unchecked box as a to-do, not a footnote.
β MVP checklist
- β Project runs; all migrations applied cleanly
- β Models registered in the admin and editable there
- β
/api/resources/,/api/questions/,/api/answers/all list & create - β Filtering (
?tags__slug=), search (?search=), and pagination work - β Anonymous users are read-only; authors can edit their own content only
- β Vote and comment actions work on all three content types
- β Only a question's author can accept an answer
- β Web UI lists, shows, creates, and edits resources
- β Test suite passes; list views have no N+1 queries
- β
README.mdwith setup steps andrequirements.txtcommitted
Submission bundle
When you package the project up, include the full source, a short README.md (how to run it + a one-paragraph overview), the test suite, and a few sentences reflecting on the hardest bug you hit and how you solved it. That reflection is often what a reviewer reads first.
What Good Looks Like
"Done" and "good" aren't the same. Use this rubric to judge your own build the way a code reviewer would β it's the difference between a project that just runs and one you're proud to show.
| Dimension | Needs work | Good |
|---|---|---|
| Models | Repeated vote/comment fields copy-pasted per model | A shared abstract base; generic relations; sensible constraints and ordering |
| API design | Inconsistent field names; hand-rolled create logic full of edge cases | Predictable, RESTful URLs; serializers do the work; nested reads, id-based writes |
| Permissions | Anyone can edit anything, or checks scattered inline | One reusable permission class; object-level checks; unauthorized returns 401/403 correctly |
| DRY | Vote/comment code duplicated across three ViewSets | Shared mixin; no logic repeated more than once |
| Performance | Query count grows with row count (N+1) | Constant query count via select_related/prefetch_related |
| Tests | None, or only happy-path | Covers permissions, validation, and the custom actions; all green |
β The mark of a strong submission
Someone can clone your repo, follow the README, and have the app running in under five minutes β then read the tests and understand exactly what the app promises to do. Clarity and reproducibility impress reviewers more than feature count.
Quiz
π― Check Your Understanding
Question 1: Why do Vote and Comment use a GenericForeignKey instead of a normal ForeignKey?
Question 2: A logged-in user tries to PATCH a resource that belongs to someone else. With IsAuthorOrReadOnly in place, what happens?
Question 3: Your resource list endpoint fires 11 queries for 10 rows. What's the most likely fix?
Summary & What's Next
π Key Takeaways
- A capstone's value is integration β connecting models, serializers, ViewSets, permissions, and templates into one working app.
- Build in vertical milestones; each one runs and is verifiable before you move on.
- Generic relations let one vote/comment mechanism serve every content type; an abstract base keeps the shared logic DRY.
- The same models power both a template UI and a REST API β presentation is the only thing that changes.
- "Good" means correct permissions, no N+1 queries, and tests that prove the promises.
π Further Reading
- Django docs β Class-based views
- DRF β ViewSets
- Django docs β Generic relations (contenttypes)
- Django docs β Database access optimization
π What's Next?
You've finished the Django module by shipping a real, two-front-door application. Next you'll switch languages entirely and meet Laravel, the leading PHP framework β and you'll be surprised how many of the concepts you just used (MVC, ORM, migrations, routing) map straight across.
π Weekend well spent!
DevExchange is portfolio-ready. Commit it, write that README, and take a well-earned break before the next stack.