Skip to main content

πŸ› οΈ 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 @action endpoints (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.

flowchart LR M1[Milestone 1
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:

DevExchange data model User owns Resources, Questions, and Answers. Resources and Questions carry many Tags. Answers belong to Questions. Votes and Comments attach generically to Resources, Questions, and Answers. User + Profile Resource title Β· url Β· desc Question title Β· body Answer body Β· is_accepted Tag Vote & Comment GenericForeignKey attach to any of the three above
Figure 1 β€” One 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.md with setup steps and requirements.txt committed

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.

DimensionNeeds workGood
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

πŸš€ 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.