10 · Capstone — A Production-Ready Book Club App¶
The capstone puts the whole course into one application that's ready to run for real
people: a book club organiser. Clubs have hosts and members; hosts schedule meetings;
members RSVP; everyone sees meeting times in their own time zone; reminders go out once,
and only to people who haven't declined. Around the features sits everything from Level
4: environment-driven settings, a content security policy, logging with request IDs,
health checks, a management command for scheduling, a clean check --deploy, and tests.
We built it on Django 6.1.1, ran its 10 tests, and served it with Gunicorn against PostgreSQL 16. The results are at the end of each section. Treat the code here as the reference solution, and build your own before reading it in full.
Requirements¶
- Custom user with a unique email and a time zone (Level 2 · 07, Level 4 · 08).
- Clubs with memberships; roles host and member. Non-members get 404s.
- Hosts schedule meetings (book, time, place). Times are entered and shown in each user's own zone.
- Members RSVP yes / maybe / no, changeable until the meeting starts, one answer per person (enforced by the database).
- A reminder for each meeting goes to every member who hasn't said no, exactly once, even if the job runs twice.
- A scheduled command enqueues reminders for meetings in the next N hours, with a dry run.
- Production settings from the environment;
check --deploy --fail-level WARNINGpasses. - CSP, request IDs in logs, liveness and readiness endpoints.
- The club page uses a constant number of queries.
Project layout¶
bookclub/
├── accounts/ # User model, time-zone middleware, admin
├── clubs/ # models, views, forms, tasks, command, templates, tests
│ ├── management/commands/send_meeting_reminders.py
│ ├── tasks.py
│ └── tests/test_clubs.py
├── config/
│ ├── settings.py # production settings, all from the environment
│ ├── settings_test.py # test overrides
│ └── request_id.py # middleware + logging filter (Level 4 · 07)
└── templates/
Users and time zones¶
import zoneinfo
from django.contrib.auth.models import AbstractUser
from django.db import models
TIMEZONE_CHOICES = [(tz, tz) for tz in sorted(zoneinfo.available_timezones()) if "/" in tz] + [("UTC", "UTC")]
class User(AbstractUser):
email = models.EmailField("email address", unique=True)
timezone = models.CharField(max_length=64, choices=TIMEZONE_CHOICES, default="UTC")
import zoneinfo
from django.utils import timezone
class UserTimezoneMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
user = getattr(request, "user", None)
if user is not None and user.is_authenticated:
timezone.activate(zoneinfo.ZoneInfo(user.timezone))
else:
timezone.deactivate()
return self.get_response(request)
It sits right after AuthenticationMiddleware. Because MeetingForm uses Django's form
handling with time-zone support on, a host in Kolkata typing "19:00" stores 13:30 UTC,
and a member in New York sees 09:30.
Models¶
class ClubQuerySet(models.QuerySet):
def for_user(self, user):
return self.filter(memberships__user=user)
class Club(models.Model):
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
created = models.DateTimeField(auto_now_add=True)
objects = ClubQuerySet.as_manager()
...
class Membership(models.Model):
club = models.ForeignKey(Club, on_delete=models.CASCADE, related_name="memberships")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name="memberships")
role = models.CharField(max_length=10, choices=Role, default=Role.MEMBER)
class Meta:
constraints = [models.UniqueConstraint(fields=["club", "user"], name="one_membership")]
class MeetingQuerySet(models.QuerySet):
def upcoming(self):
return self.filter(starts_at__gte=timezone.now()).order_by("starts_at")
class Meeting(models.Model):
club = models.ForeignKey(Club, on_delete=models.CASCADE, related_name="meetings")
book_title = models.CharField(max_length=200)
starts_at = models.DateTimeField()
location = models.CharField(max_length=200)
reminder_sent_at = models.DateTimeField(null=True, blank=True)
objects = MeetingQuerySet.as_manager()
class Meta:
ordering = ["starts_at"]
indexes = [models.Index(fields=["club", "starts_at"], name="meeting_club_starts")]
class RSVP(models.Model):
class Answer(models.TextChoices):
YES = "yes", "Going"
MAYBE = "maybe", "Maybe"
NO = "no", "Not going"
meeting = models.ForeignKey(Meeting, on_delete=models.CASCADE, related_name="rsvps")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name="rsvps")
answer = models.CharField(max_length=5, choices=Answer)
updated = models.DateTimeField(auto_now=True)
class Meta:
constraints = [models.UniqueConstraint(fields=["meeting", "user"], name="one_rsvp_per_member")]
reminder_sent_at is the idempotency marker for requirement 5. The (club, starts_at)
index serves the club page's "upcoming meetings for this club" query.
Views¶
def get_club_and_membership(user, pk):
club = get_object_or_404(Club.objects.for_user(user), pk=pk)
return club, Membership.objects.get(club=club, user=user)
@login_required
def club_detail(request, pk):
club, membership = get_club_and_membership(request.user, pk)
meetings = (club.meetings.upcoming()
.annotate(going=Count("rsvps", filter=Q(rsvps__answer=RSVP.Answer.YES)))
.prefetch_related(Prefetch("rsvps", queryset=RSVP.objects.filter(user=request.user),
to_attr="my_rsvp")))
return render(request, "clubs/club_detail.html",
{"club": club, "membership": membership, "meetings": meetings})
@login_required
def meeting_create(request, pk):
club, membership = get_club_and_membership(request.user, pk)
if membership.role != Role.HOST:
raise PermissionDenied("Only hosts can schedule meetings.")
form = MeetingForm(request.POST or None)
if request.method == "POST" and form.is_valid():
meeting = form.save(commit=False)
meeting.club = club
meeting.save()
messages.success(request, "Meeting scheduled.")
return redirect(club)
return render(request, "clubs/meeting_form.html", {"club": club, "form": form})
@login_required
@require_POST
def rsvp(request, pk, meeting_pk):
club, membership = get_club_and_membership(request.user, pk)
meeting = get_object_or_404(club.meetings.upcoming(), pk=meeting_pk)
form = RSVPForm(request.POST)
if form.is_valid():
RSVP.objects.update_or_create(meeting=meeting, user=request.user,
defaults={"answer": form.cleaned_data["answer"]})
messages.success(request, "Thanks, your answer is saved.")
return redirect(meeting)
The club page shows, per meeting, how many are going (an annotation) and the current
user's own answer (a filtered Prefetch with to_attr), so its query count doesn't grow
with the number of meetings. RSVPs are looked up through club.meetings.upcoming(): you
can't answer for another club's meeting or for one that has already started.
update_or_create plus the unique constraint makes changing your answer an update, never a
duplicate.
The reminder task and the scheduling command¶
@task
def send_meeting_reminder(meeting_id):
"""Email every member who hasn't declined. Safe to run more than once."""
with transaction.atomic():
meeting = (Meeting.objects.select_for_update()
.select_related("club").filter(pk=meeting_id).first())
if meeting is None or meeting.reminder_sent_at is not None:
return 0
declined = meeting.rsvps.filter(answer="no").values("user_id")
recipients = list(meeting.club.memberships.exclude(user_id__in=declined)
.values_list("user__email", flat=True))
for email in recipients:
send_mail(f"Reminder: {meeting.book_title}",
f"{meeting.club.name} meets at {meeting.location}.",
None, [email])
meeting.reminder_sent_at = timezone.now()
meeting.save(update_fields=["reminder_sent_at"])
return len(recipients)
- Idempotent: it checks and sets
reminder_sent_atinside one transaction. - Concurrency-safe on PostgreSQL:
select_for_update()means two workers handed the same meeting can't both pass the check (Level 3 · 05). (select_related("club")joins the club; to lock only the meeting row, addof=("self",).) - Takes an ID, re-fetches, and tolerates the meeting having been deleted.
class Command(BaseCommand):
help = "Enqueue reminders for meetings starting within the next N hours."
def add_arguments(self, parser):
parser.add_argument("--hours", type=int, default=24)
parser.add_argument("--dry-run", action="store_true")
def handle(self, hours, dry_run, **options):
now = timezone.now()
due = Meeting.objects.filter(starts_at__gte=now, starts_at__lte=now + timedelta(hours=hours),
reminder_sent_at__isnull=True)
count = 0
for meeting in due.iterator():
count += 1
if dry_run:
self.stdout.write(f"would remind: {meeting} at {meeting.starts_at:%Y-%m-%d %H:%M} UTC")
else:
transaction.on_commit(partial(send_meeting_reminder.enqueue, meeting.pk))
verb = "Would enqueue" if dry_run else "Enqueued"
self.stdout.write(self.style.SUCCESS(f"{verb} {count} reminder(s)."))
Run it hourly from cron or your platform's scheduler. With the default
ImmediateBackend the reminder runs inside the command; with a worker-backed tasks
backend (set DJANGO_TASKS_BACKEND) it runs in a worker. Either way the code doesn't
change (Level 3 · 08).
Settings¶
config/settings.py is the environment-driven file from Level 4 · 02, plus:
MIDDLEWARE = [
"config.request_id.RequestIDMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"accounts.middleware.UserTimezoneMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django.middleware.csp.ContentSecurityPolicyMiddleware",
]
SECURE_REDIRECT_EXEMPT = [r"^healthz$", r"^readyz$"]
SECURE_CSP = {
"default-src": [CSP.SELF],
"script-src": [CSP.SELF, CSP.NONCE],
"style-src": [CSP.SELF],
"img-src": [CSP.SELF, "data:"],
"frame-ancestors": [CSP.NONE],
"form-action": [CSP.SELF],
}
AUTH_USER_MODEL = "accounts.User"
TASKS = {"default": {"BACKEND": env("DJANGO_TASKS_BACKEND",
"django.tasks.backends.immediate.ImmediateBackend")}}
# Subdomains and preload stay off until the whole domain is HTTPS-only (see README).
SILENCED_SYSTEM_CHECKS = ["security.W005", "security.W021"]
plus the LOGGING config with request IDs from Level 4 · 07 (with
django.security.DisallowedHost routed nowhere, so host-header scanners don't flood the
logs). No templates contain inline styles or scripts, so the strict policy needs no
exceptions.
Test settings, and a failure that justified them¶
Our first test run failed three tests with:
The production settings use WhiteNoise's manifest storage, which needs collectstatic
to have run, and tests render templates without it (Level 1 · 09 predicted exactly this).
Rather than weakening production settings, a small test module overrides what tests need:
"""Test settings: production settings with test-friendly overrides."""
import os
os.environ.setdefault("DJANGO_SECRET_KEY", "test-only-not-secret")
os.environ.setdefault("DJANGO_ALLOWED_HOSTS", "testserver")
from .settings import * # noqa: E402,F403
# The manifest storage needs collectstatic; tests render templates without it.
STORAGES = {**STORAGES, "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}}
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
SECURE_SSL_REDIRECT = False
MAILERS = {"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}}
TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}}
LOGGING["root"]["level"] = "WARNING"
MIDDLEWARE = [m for m in MIDDLEWARE if m != "whitenoise.middleware.WhiteNoiseMiddleware"]
LOGGING["loggers"]["django.request"]["level"] = "ERROR"
Keep a CI step that runs collectstatic with the real settings as well, so the manifest
problem is still caught where it matters.
Tests¶
Ten tests, one per requirement or risk:
| Test | Proves |
|---|---|
test_outsider_gets_404 |
non-members can't see clubs or meetings |
test_only_host_schedules |
member 403, host 200 on the schedule page |
test_rsvp_is_created_then_updated_not_duplicated |
answering twice updates one row |
test_rsvp_requires_post |
GET → 405 |
test_cannot_rsvp_to_past_meeting |
past meetings → 404 |
test_meeting_time_shown_in_each_users_zone |
same instant, each user's local clock time on the page |
test_reminder_skips_members_who_declined_and_is_idempotent |
one email, to the right person; second run sends none |
test_command_dry_run_and_real_run |
dry run sends nothing; real run (with captureOnCommitCallbacks) emails both members and sets reminder_sent_at |
test_club_page_query_count_does_not_grow_with_meetings |
6 queries with 21 meetings |
test_health_endpoints |
/healthz and /readyz return ok |
The reminder test, for example:
def test_reminder_skips_members_who_declined_and_is_idempotent(self):
RSVP.objects.create(meeting=self.meeting, user=self.member, answer="no")
self.assertEqual(send_meeting_reminder.call(self.meeting.pk), 1)
self.assertEqual([m.to for m in mail.outbox], [["hana@example.com"]])
self.assertEqual(send_meeting_reminder.call(self.meeting.pk), 0)
self.assertEqual(len(mail.outbox), 1)
Result:
Found 10 test(s).
System check identified no issues (0 silenced).
..........
Ran 10 tests in 0.068s
OK
Production checks and a real run¶
With a generated DJANGO_SECRET_KEY, DJANGO_ALLOWED_HOSTS=bookclub.example,127.0.0.1,
a PostgreSQL DATABASE_URL and the SMTP email backend:
$ python manage.py check --deploy --fail-level WARNING
System check identified no issues (2 silenced).
$ python manage.py makemigrations --check --dry-run
No changes detected
$ python manage.py collectstatic --noinput
131 static files copied to '.../staticfiles', 393 post-processed.
Served by Gunicorn (two workers) against PostgreSQL:
GET /healthz → {"status": "ok"}
GET /readyz → {"status": "ok"}
GET / → HTTP/1.1 302 Found
Location: /accounts/login/?next=/
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';
img-src 'self' data:; frame-ancestors 'none'; form-action 'self'
X-Request-ID: ab7fc073f007
(The nonce only appears in the policy on pages that use one; this redirect didn't.) We
then seeded a club with host hana (Asia/Kolkata) and member mo (America/New_York) and
a meeting at 13:30 UTC on 4 October, logged in as each, and read the club page:
hana 200 <time datetime="2026-10-04T19:00:00+05:30">Sun 4 Oct, 19:00 IST</time>
queries 6
mo 200 <time datetime="2026-10-04T09:30:00-04:00">Sun 4 Oct, 09:30 EDT</time>
queries 6
One meeting, two correct local times, the same six queries for each user.
How It Actually Works¶
Follow a reminder from schedule to inbox. Cron runs send_meeting_reminders hourly. The
command selects meetings starting in the next 24 hours with no reminder_sent_at, and
for each registers an on_commit callback; call_command isn't in an atomic block, so on
autocommit those run immediately. enqueue() hands the meeting ID to the configured tasks
backend. The task opens a transaction, locks the meeting row, re-checks
reminder_sent_at, computes recipients with a single subquery (members minus those who
said no), sends the mails, stamps the row and commits, releasing the lock. If a second
worker received the same meeting, it waits on the lock, then sees the stamp and returns 0.
Every layer, from database constraints to the task's re-check, assumes that something
upstream might run twice.
Where to take it next¶
- API: add DRF endpoints for clubs, meetings and RSVPs, reusing
for_user()and the role checks (Level 3 · 10 is the template). - Invitations: hosts invite by email with a signed, expiring link
(
django.core.signing.TimestampSigner). - Calendar export: an
.icsdownload per meeting, generated in UTC with aTZID. - Translations: French or your own language, with plurals tested (Level 4 · 08).
- Deploy it to a platform you have access to, with PostgreSQL, a worker-backed tasks
backend and a scheduled job, and keep
check --deployin CI.
Common mistakes¶
- Treating "send once" as a hope instead of a stamped, locked, re-checked fact.
- Storing local times instead of UTC instants plus each user's zone name.
- Scoping only the club view and forgetting meetings and RSVPs.
- Weakening production settings to make tests pass; override in test settings instead.
- Inline scripts and styles that force
'unsafe-inline'into the CSP.
Exercise¶
Build the capstone from the requirements, without copying the reference code first. Then:
- Make all ten tests pass, plus three of your own for features you add.
- Get
check --deploy --fail-level WARNINGclean with production-like environment variables, and record any silenced check with a reason. - Measure the club page with 100 meetings and 50 members and keep it at a constant query count.
- Run the reminder command twice concurrently against PostgreSQL (two terminals) and confirm each member receives exactly one email.