Table of Contents
- Django "Classical" Assignments — Complete Tutorial Guide
- Prerequisites
- Assignment 01: Basic To-Do List
- Assignment 02: Personal Blog
- Assignment 03: User Profile & Image Upload
- Assignment 04: Task Manager with Background Tasks
- Assignment 05: Weather Dashboard
- Assignment 06: Real-time Chat
- Assignment 07: Form Handling & Templates
- Assignment 08: Webhook Receiver
- Assignment 09: Inventory Management
- Assignment 10: Secure Document Vault
Django "Classical" Assignments — Complete Tutorial Guide
This guide walks through all ten assignments in the django-assignments/classical/ directory. Classical Django uses server-rendered HTML templates — the server generates complete HTML pages and delivers them to the browser. Every assignment is a standalone Django project with a consistent two-package layout: config/ for project settings and app/ for the application code.
Prerequisites
Python Version
python --version # 3.10 or later required
Virtual Environment
python -m venv venv
source venv/bin/activate # macOS/Linux
venv\Scripts\activate # Windows
The manage.py Tool
Every assignment is started the same way:
pip install Django
python manage.py migrate
python manage.py runserver
The dev server listens on http://127.0.0.1:8000 and reloads automatically when you save a file.
Project Layout
Every assignment folder uses the same structure:
<assignment>/
├── manage.py
├── config/
│ ├── settings.py ← database, installed apps, static/media config
│ ├── urls.py ← top-level routing → includes app/urls.py
│ └── asgi.py ← ASGI entry point (used for assignment 06)
└── app/
├── models.py ← database tables
├── views.py ← request handlers
├── urls.py ← URL patterns for this app
├── admin.py ← Django admin registration
└── templates/
└── app/ ← HTML templates
Assignment 01: Basic To-Do List
Location: django-assignments/classical/01-basic-todo-api/
What You Will Learn
- Defining a Django model and running migrations
- Handling GET and POST in one view function
- Using
@require_http_methodsto restrict allowed HTTP verbs - Redirecting after a POST to avoid form re-submission
- Partial model saves with
update_fields
Key Concepts
@require_http_methods(["GET", "POST"]) is a decorator that returns 405 Method Not Allowed if the request arrives with any other verb (e.g. DELETE, PUT). It is a lightweight guard that removes the need for a manual if request.method not in (...) check.
redirect("index") sends a 302 Found response pointing at the URL named "index". After a successful POST, you should always redirect rather than rendering directly — this prevents the browser from re-submitting the form if the user refreshes the page (the POST/Redirect/GET pattern).
save(update_fields=["done"]) tells Django to issue an UPDATE that touches only the done column, rather than updating every column in the row. For a model with many fields this is noticeably more efficient and avoids accidental overwrites if multiple processes are saving the same object concurrently.
Setup
pip install Django
python manage.py migrate
python manage.py runserver
Project Files
app/models.py
class Todo(models.Model):
title = models.CharField(max_length=200)
done = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
auto_now_add=True sets the timestamp once — at creation — and never changes it. Compare with auto_now=True, which updates the timestamp on every save().
app/views.py
@require_http_methods(["GET", "POST"])
def index(request):
if request.method == "POST":
title = (request.POST.get("title") or "").strip()
if title:
Todo.objects.create(title=title)
return redirect("index")
todos = Todo.objects.all().order_by("-created_at")
return render(request, "app/index.html", {"todos": todos})
@require_http_methods(["POST"])
def toggle_done(request, todo_id: int):
todo = get_object_or_404(Todo, id=todo_id)
todo.done = not todo.done
todo.save(update_fields=["done"])
return redirect("index")
@require_http_methods(["POST"])
def delete(request, todo_id: int):
todo = get_object_or_404(Todo, id=todo_id)
todo.delete()
return redirect("index")
toggle_done and delete accept only POST. In HTML there are no DELETE buttons — a <form method="post"> pointing at the toggle or delete URL is the conventional way to trigger these actions from a browser.
app/urls.py
urlpatterns = [
path("", index, name="index"),
path("todos/<int:todo_id>/toggle/", toggle_done, name="toggle_done"),
path("todos/<int:todo_id>/delete/", delete, name="delete"),
]
Testing Step by Step
- Open
http://127.0.0.1:8000/. You see an empty list and a form. - Enter a title and submit. The page reloads with the new item.
- Click "Toggle" on the item — it is marked done.
- Click "Delete" — the item disappears.
- Visit
http://127.0.0.1:8000/admin/(after creating a superuser withpython manage.py createsuperuser) to manage todos directly from the admin interface.
Assignment 02: Personal Blog
Location: django-assignments/classical/02-personal-blog/
What You Will Learn
SlugFieldand its constraints- Slug-based URL patterns with
<slug:slug> - Two-template pattern: list view + detail view
- Using the Django admin to create content
Key Concepts
SlugField is a CharField that validates slug format — lowercase letters, numbers, and hyphens only. Setting unique=True creates a database-level uniqueness constraint so no two posts can share the same slug, regardless of application-layer checks.
<slug:slug> URL converter matches only strings that look like valid slugs and passes the value to the view as a str. The URL /posts/my-first-post/ passes "my-first-post" as slug.
get_object_or_404(Post, slug=slug) is a one-liner that does Post.objects.get(slug=slug) and, if the post does not exist, raises Django's built-in 404 response. This is cleaner and more explicit than wrapping get() in a try/except.
Setup
pip install Django
python manage.py makemigrations
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
Project Files
app/models.py
class Post(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
app/views.py
def index(request):
posts = Post.objects.all().order_by("-created_at")
return render(request, "app/index.html", {"posts": posts})
def detail(request, slug: str):
post = get_object_or_404(Post, slug=slug)
return render(request, "app/detail.html", {"post": post})
app/urls.py
urlpatterns = [
path("", index, name="index"),
path("posts/<slug:slug>/", detail, name="detail"),
]
Testing Step by Step
- Log into
http://127.0.0.1:8000/admin/and create two or three posts with distinct slugs (e.g.hello-world,second-post). - Visit
http://127.0.0.1:8000/— the post list appears. - Click a title. The URL changes to
http://127.0.0.1:8000/posts/hello-world/. - Try creating a second post with the same slug in the admin. Django rejects it with a validation error.
- Try accessing a non-existent slug:
http://127.0.0.1:8000/posts/does-not-exist/→ 404.
Assignment 03: User Profile & Image Upload
Location: django-assignments/classical/03-user-profile-uploads/
What You Will Learn
FileFieldwithupload_toauto_now=Truefor "last updated" timestamps- Reading uploaded files from
request.FILES - Conditionally updating only the avatar when a new file is provided
- Serving media files in development
Key Concepts
auto_now=True updates the field to the current timestamp every time save() is called, making it ideal for "last modified" tracking. Unlike auto_now_add=True, it cannot be set manually.
upload_to="avatars/" instructs Django to place uploaded files inside MEDIA_ROOT/avatars/. The MEDIA_ROOT and MEDIA_URL settings (in config/settings.py) must be configured, and in development the static() helper in config/urls.py serves these files.
Conditional file update: If the user submits the profile form without choosing a new file, request.FILES.get("avatar") returns None. The view checks for this and only replaces profile.avatar when a new file was actually uploaded:
if avatar is not None:
profile.avatar = avatar
Without this check, submitting the form without a file would erase the existing avatar.
Setup
pip install Django pillow
python manage.py migrate
python manage.py runserver
pillow is required by Django when ImageField is used. This assignment uses FileField (accepts any file), so pillow is optional but recommended for a complete setup.
Project Files
app/models.py
class Profile(models.Model):
display_name = models.CharField(max_length=100)
bio = models.TextField(blank=True)
avatar = models.FileField(upload_to="avatars/", blank=True, null=True)
updated_at = models.DateTimeField(auto_now=True)
app/views.py
@require_http_methods(["GET", "POST"])
def index(request):
profile = Profile.objects.first()
if request.method == "POST":
display_name = (request.POST.get("display_name") or "").strip()
bio = (request.POST.get("bio") or "").strip()
avatar = request.FILES.get("avatar")
if profile is None:
profile = Profile.objects.create(
display_name=display_name, bio=bio, avatar=avatar)
else:
profile.display_name = display_name
profile.bio = bio
if avatar is not None:
profile.avatar = avatar
profile.save()
return redirect("index")
return render(request, "app/index.html", {"profile": profile})
The template form must include enctype="multipart/form-data" for the file upload to work:
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
...
</form>
Without enctype, the browser sends the form as URL-encoded text, and request.FILES is empty.
Testing Step by Step
- Open
http://127.0.0.1:8000/. The profile is empty initially. - Fill in a display name, bio, and select a file. Submit. The profile is created.
- Submit again without choosing a new file — the existing avatar is preserved.
- The avatar is accessible at
http://127.0.0.1:8000/media/avatars/<filename>.
Assignment 04: Task Manager with Background Tasks
Location: django-assignments/classical/04-task-manager-bg-tasks/
What You Will Learn
transaction.on_commit— the correct place to trigger side effects in Django- Daemon threads for lightweight background work
- Why firing a thread directly after
save()can cause data-race issues
Key Concepts
transaction.on_commit(callback) registers a function to run after the current database transaction commits successfully. This is critical for background work that reads the data just saved: if you start a thread immediately after save(), the transaction may not yet be committed — another database connection in the thread could read stale or missing data. on_commit guarantees the data is visible to all connections before the callback fires.
Daemon threads (daemon=True) are threads that do not prevent the Python process from exiting. If the web server shuts down while a daemon thread is running, the thread is killed immediately. For non-critical notifications this is acceptable. For critical work (payment processing, for example) use a proper task queue.
def _notify_after_commit(task_id: int) -> None:
transaction.on_commit(
lambda: threading.Thread(
target=_send_notification,
args=(task_id,),
daemon=True
).start()
)
The lambda defers the thread creation until after commit. The thread starts only if the transaction succeeds — if the save() is rolled back (due to an exception), no thread is created.
Setup
pip install Django
python manage.py migrate
python manage.py runserver
Project Files
app/views.py
@require_http_methods(["POST"])
def complete(request, task_id: int):
task = get_object_or_404(Task, id=task_id)
if not task.completed:
task.completed = True
task.save(update_fields=["completed"])
_notify_after_commit(task.id)
return redirect("index")
The guard if not task.completed prevents double-completion: clicking "Complete" on an already-completed task does nothing and fires no notification.
Testing Step by Step
- Create several tasks via the form.
- Click "Complete" on a task. The page redirects immediately.
- Because
_send_notificationis a no-op in this scaffold, the terminal shows nothing. To observe the background pattern: replace thereturnin_send_notificationwithtime.sleep(3); print(f"Notified for task {task_id}")and repeat.
Assignment 05: Weather Dashboard
Location: django-assignments/classical/05-weather-dashboard/
What You Will Learn
- GET form submission (query parameters)
- Calling an external API with
requests django.core.cachefor in-memory TTL caching- The
wttr.inJSON API
Key Concepts
wttr.in is a free, no-auth weather API. The endpoint https://wttr.in/{city}?format=j1 returns a detailed JSON response. This is a different API from the Open-Meteo used in other framework assignments — it accepts city names directly, which simplifies the form to a single text input.
django.core.cache provides a unified caching interface. The default backend (LocMemCache) stores data in the process's memory. cache.set(key, value, timeout=300) stores with a 5-minute TTL; cache.get(key) returns None if the entry is absent or expired.
cache_key = f"weather:{city.lower()}"
data = cache.get(cache_key)
if data is None:
r = requests.get(f"https://wttr.in/{city}", params={"format": "j1"}, timeout=5)
r.raise_for_status()
data = r.json()
cache.set(cache_key, data, timeout=300)
The cache key uses city.lower() so that "London" and "london" hit the same cache entry.
GET form: The search form uses method="get", which appends the city name to the URL as a query parameter (?city=London). This makes the result page bookmarkable — the URL fully describes the query.
Setup
pip install Django requests
python manage.py migrate
python manage.py runserver
Testing Step by Step
- Open
http://127.0.0.1:8000/. Submit "London" in the form. - Weather data for London appears. Note the response time.
- Submit "London" again — the response is nearly instant (served from cache).
- Submit "Paris" — a real API call is made.
Common Issues
requests.exceptions.Timeout— the wttr.in API has a 5-second timeout in the code. If the network is slow, you may see an error message rendered in the template.KeyErrorin template — thewttr.inJSON structure is nested. Inspectdatain the Django shell to understand its shape:python manage.py shell→import requests; print(requests.get("https://wttr.in/London?format=j1").json().keys()).
Assignment 06: Real-time Chat
Location: django-assignments/classical/06-real-time-chat/
What You Will Learn
- Why standard Django (WSGI) cannot handle WebSockets
- ASGI and the
ProtocolTypeRouter - Django Channels and
AsyncWebsocketConsumer - Channel layers and group broadcasting
- Running Django under Daphne (ASGI server)
Key Concepts
WSGI vs ASGI: Django's standard deployment protocol (WSGI) is synchronous and request-response oriented — a connection is opened, a response is returned, and the connection closes. WebSockets stay open indefinitely. Django Channels adds an ASGI layer that handles both HTTP and persistent WebSocket connections concurrently.
ProtocolTypeRouter in config/asgi.py routes incoming connections to different handlers based on protocol type:
application = ProtocolTypeRouter({
"http": django_asgi_app,
"websocket": AuthMiddlewareStack(URLRouter(app.routing.websocket_urlpatterns)),
})
HTTP requests are handled by Django's normal view machinery. WebSocket connections go through URLRouter, which matches the path against websocket_urlpatterns.
AsyncWebsocketConsumer is the base class for WebSocket handlers. The lifecycle:
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.group_name = "chat"
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept() # complete the WebSocket handshake
async def disconnect(self, close_code):
await self.channel_layer.group_discard(self.group_name, self.channel_name)
async def receive(self, text_data=None, bytes_data=None):
payload = json.loads(text_data)
username = (payload.get("username") or "anonymous")[:50]
text = (payload.get("text") or "")[:2000]
await self.channel_layer.group_send(
self.group_name,
{"type": "chat.message", "username": username, "text": text},
)
async def chat_message(self, event): # called by group_send
await self.send(text_data=json.dumps({
"username": event["username"],
"text": event["text"]
}))
Channel layer is a message-passing system shared between all consumer instances (all connected users). group_send publishes a message to a named group; every consumer in the group receives it via the method whose name matches event["type"] (dots become underscores: chat.message → chat_message).
Input validation is applied in receive: usernames are capped at 50 characters and message texts at 2000, matching the model field limits.
Setup
pip install Django channels channels-redis
# Requires a running Redis instance for the channel layer.
# For local development without Redis, use the in-memory layer:
# pip install channels (no Redis needed for InMemoryChannelLayer)
python manage.py migrate
Run with Daphne (ASGI server):
pip install daphne
daphne config.asgi:application
Or with Uvicorn:
pip install uvicorn
uvicorn config.asgi:application --reload
python manage.py runserver uses WSGI and does not support WebSockets.
Testing Step by Step
- Start the server with Daphne or Uvicorn.
- Open
http://127.0.0.1:8000/in one browser tab. - Open the same URL in a second tab.
- Send a message from Tab 1. It appears instantly in Tab 2.
- Close Tab 1. Tab 2 continues working — the consumer's
disconnectmethod cleanly removes the closed connection from the group.
Assignment 07: Form Handling & Templates
Location: django-assignments/classical/07-forms-templates/
What You Will Learn
- The Django
messagesframework for one-shot user feedback - Storing form submissions in the database
- Differentiating validation (required fields) from model creation
- The POST/Redirect/GET pattern in practice
Key Concepts
django.contrib.messages is a cookie/session-based framework for one-time notifications. After a successful form submission you add a message and redirect:
messages.success(request, "Message sent")
return redirect("index")
On the next request (the GET after the redirect), the template renders the message and then it is discarded automatically. Messages survive exactly one request — they are not shown again on subsequent refreshes.
In-browser persistence vs database: This assignment saves every contact form submission to the ContactMessage table. This is appropriate for a contact form — you want a record of who wrote what. Contrast with the messages framework itself, which stores nothing permanently.
Setup
pip install Django
python manage.py migrate
python manage.py runserver
Project Files
app/models.py
class ContactMessage(models.Model):
email = models.EmailField()
topic = models.CharField(max_length=100)
message = models.TextField(max_length=2000)
created_at = models.DateTimeField(auto_now_add=True)
EmailField stores the value as a VARCHAR and applies email format validation in the Django admin. In this view the validation is manual; in a form-class-based approach EmailField would validate automatically.
app/views.py
@require_http_methods(["GET", "POST"])
def index(request):
if request.method == "POST":
email = (request.POST.get("email") or "").strip()
topic = (request.POST.get("topic") or "").strip()
message = (request.POST.get("message") or "").strip()
if email and topic and message:
ContactMessage.objects.create(email=email, topic=topic, message=message)
messages.success(request, "Message sent")
return redirect("index")
return render(request, "app/index.html")
The template renders the message list via {% if messages %}...{% for m in messages %}{{ m }}{% endfor %}{% endif %}.
Testing Step by Step
- Open
http://127.0.0.1:8000/. - Submit the contact form with all fields filled. A success banner appears on the redirected page.
- Refresh the page. The banner is gone — messages are one-shot.
- Submit with an empty field. The form re-renders silently (no message, no redirect). To improve this, add explicit error messages for incomplete submissions.
Assignment 08: Webhook Receiver
Location: django-assignments/classical/08-webhook-receiver/
What You Will Learn
- CSRF exemption for machine-to-machine endpoints
- HMAC-SHA256 signature verification
- Timestamp validation to prevent replay attacks
- Idempotency via
event_iddeduplication - Storing raw webhook payloads in a
JSONField
Key Concepts
Replay attacks: An attacker who intercepts a legitimate webhook request could re-send it later to trigger the same action twice (e.g. double-crediting a payment). Timestamp validation mitigates this: the receiver rejects requests where the X-Timestamp header is more than 300 seconds old. This is more advanced than the webhook implementations in FastAPI, Flask, and Hono.
X-Event-Id deduplication: Some providers retry failed webhook deliveries. The event_id header uniquely identifies each event. Before processing, the receiver checks whether an event with the same provider+event_id combination was already stored:
if event_id and WebhookEvent.objects.filter(
provider=provider, event_id=event_id).exists():
return HttpResponse("duplicate event", status=409)
This makes the endpoint idempotent — processing the same event twice has no effect.
JSONField stores a Python dict as a JSON column in the database. Django 3.1+ includes JSONField in the standard models. It is queryable: WebhookEvent.objects.filter(payload__event="payment.success").
Important — request.body vs request.POST: The HMAC is computed over the raw bytes. Using request.body gives you those bytes directly. If you access request.POST first, Django reads and parses the body, emptying the underlying stream — request.body would then be empty.
raw = request.body # raw bytes — used for HMAC
provided = request.headers.get("X-Signature", "")
expected = hmac.new(
settings.WEBHOOK_SECRET.encode("utf-8"),
raw,
hashlib.sha256
).hexdigest()
valid = hmac.compare_digest(expected, provided)
WEBHOOK_SECRET is read from settings.py. In production it should come from an environment variable.
Setup
pip install Django
python manage.py migrate
python manage.py runserver
Testing Step by Step
Use a Python script to send a correctly signed request:
import hashlib, hmac, json, time
import requests
SECRET = "change-me"
PROVIDER = "github"
payload = {"event": "push", "repo": "my-project"}
body = json.dumps(payload).encode()
ts = str(int(time.time()))
event_id = "evt-001"
sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
resp = requests.post(
f"http://127.0.0.1:8000/webhooks/{PROVIDER}/",
data=body,
headers={
"Content-Type": "application/json",
"X-Signature": sig,
"X-Timestamp": ts,
"X-Event-Id": event_id,
},
)
print(resp.status_code, resp.text)
- Correct signature →
200 ok - Wrong signature →
401 invalid signature(but the event is still stored withsignature_valid=False) - Timestamp older than 300s →
400 timestamp too old - Same event_id again →
409 duplicate event
Assignment 09: Inventory Management
Location: django-assignments/classical/09-inventory-management/
What You Will Learn
- Django's
Paginatorclass Qobjects for OR-based database queries- Preserving query parameters across pagination links
- Case-insensitive search with
__icontains
Key Concepts
Q objects allow combining filter conditions with logical operators. Standard .filter(name=q, sku=q) would produce an AND query (both fields must match). To search across either field, use | (OR):
qs = qs.filter(Q(name__icontains=q) | Q(sku__icontains=q))
__icontains is a case-insensitive substring match (LIKE %q% in SQL).
Paginator(queryset, per_page) splits the queryset into pages. get_page(n) is robust — it returns the first page for n < 1 and the last page for n > num_pages, so malformed ?page= values never crash.
paginator = Paginator(qs, 20)
page_obj = paginator.get_page(request.GET.get("page") or "1")
page_obj passed to the template provides: page_obj.object_list (current page items), page_obj.has_previous(), page_obj.has_next(), page_obj.previous_page_number(), page_obj.next_page_number(), page_obj.number, page_obj.paginator.num_pages.
Preserving query parameters in pagination links: If the user searches for q=bolt, the "Next" link must carry the search term: ?q=bolt&page=2. In the template:
<a href="?q={{ q }}&page={{ page_obj.next_page_number }}">Next →</a>
Setup
pip install Django
python manage.py makemigrations
python manage.py migrate
python manage.py runserver
Populate the database via the Django admin (python manage.py createsuperuser first) or a management command.
Testing Step by Step
- Add 25+ items via the admin.
- Open
http://127.0.0.1:8000/. Page 1 shows 20 items with a "Next" link. - Click "Next" → page 2.
- Use the search box to type a partial name or SKU. Results are filtered and paginated independently.
- Search + paginate: verify the search term is preserved in the pagination links.
Assignment 10: Secure Document Vault
Location: django-assignments/classical/10-secure-document-vault/
What You Will Learn
@login_requiredand session-based authentication- Django's built-in groups as a role mechanism
- Object-level access control: a user may only download their own documents
FileResponsefor secure file deliveryHttp404as an access-denied response
Key Concepts
@login_required redirects unauthenticated requests to /accounts/login/?next=<original_url>. Django's built-in login view handles the form; after successful login the user is redirected back to the original page.
Groups as roles: Django ships with a Group model. Adding a user to the "admin" group grants admin-level access without giving full superuser status. The helper:
def _is_admin(user) -> bool:
return user.is_superuser or user.groups.filter(name="admin").exists()
Superusers bypass the group check, which is consistent with Django's permission model (superusers always pass all permission checks).
Object-level access control in download:
@login_required
def download(request, doc_id: int):
doc = get_object_or_404(Document, id=doc_id)
if not (_is_admin(request.user) or doc.owner_id == request.user.id):
raise Http404()
return FileResponse(
doc.file.open("rb"),
as_attachment=True,
filename=doc.file.name.rsplit("/", 1)[-1]
)
Returning Http404 instead of 403 Forbidden is a deliberate choice: it avoids leaking the existence of documents the user is not allowed to see. An attacker enumerating document IDs cannot distinguish between "this document does not exist" and "this document exists but you cannot see it".
FileResponse streams the file from disk to the client without loading it into memory entirely. as_attachment=True sets Content-Disposition: attachment, which causes the browser to download the file rather than attempt to display it. The filename parameter sanitises the stored path — rsplit("/", 1)[-1] strips any directory prefix.
ForeignKey(settings.AUTH_USER_MODEL, ...) uses the setting rather than importing User directly. This makes the code compatible with projects that use a custom user model.
Setup
pip install Django
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
To create a regular user and an admin-group user for testing, use the Django admin at http://127.0.0.1:8000/admin/.
Testing Step by Step
- Log in as the superuser. Upload a document via
http://127.0.0.1:8000/upload/. - Visit
http://127.0.0.1:8000/. You see the document in the list. - Click "Download". The file is delivered as an attachment.
- Create a second user in the admin (no groups). Log in as that user.
- The list is empty — the second user owns no documents.
- Try to download the superuser's document by guessing its URL:
http://127.0.0.1:8000/documents/1/download/→ 404. - Add the second user to the
"admin"group in Django admin. Log in again. Now all documents are visible and downloadable.