Building the core: appointments and test reports
With the data model designed, building the core features is largely a matter of putting behaviour where the design said it goes. This lesson builds Nidaan's two central flows — booking and completing appointments, and recording test reports — applying the course's discipline: business logic in the right layer, queries efficient, and every list free of N+1. It is where the earlier modules stop being separate topics and become one working application.
The booking flow: a form, a thin view, and a manager
Booking an appointment is a form submission. The pieces you built module by module compose here — a
ModelForm, a thin view following POST-redirect-GET, and a manager for the queries:
# forms.py
class AppointmentForm(forms.ModelForm):
class Meta:
model = Appointment
fields = ["patient", "doctor", "scheduled_for"] # explicit fields, never "__all__"
# views.py — thin: fetch, delegate, respond
def appointment_create(request):
if request.method == "POST":
form = AppointmentForm(request.POST)
if form.is_valid():
appt = form.save()
messages.success(request, f"Booked {appt.patient} with {appt.doctor}.")
return redirect("appointment_list")
else:
form = AppointmentForm()
return render(request, "appointments/form.html", {"form": form})
The view does only HTTP plumbing (verified pattern: valid POST → 302 + created, invalid → 200 + errors). The
double-booking rule is not checked in the view — the UniqueConstraint from the design enforces it in the
database, so even a race cannot create two appointments in one slot; the view simply catches the resulting
error and shows a friendly message. Logic in the layer that can enforce it correctly.
The list, without N+1
The appointment list is exactly where the N+1 problem hides — each row shows the patient and doctor, both across a relationship:
def appointment_list(request):
appointments = (
Appointment.objects
.select_related("patient", "doctor") # one query, not 1 + N
.order_by("-scheduled_for")
)
return render(request, "appointments/list.html", {"appointments": appointments})
select_related("patient", "doctor") joins both in a single query (verified earlier: N+1 dropped from 6
queries to 1). Without it, a list of 50 appointments touching appt.patient.name and appt.doctor.name in
the template would fire 101 queries. This is the discipline the query lessons drilled: any list that
crosses a relationship gets select_related/prefetch_related. Building the core is where you apply it by
reflex.
Completing an appointment: behaviour on the model
Completing an appointment is behaviour of an appointment — set the status, compute the fee, record the payment — so it lives on the model as a method (fat model), wrapped in a transaction because it makes several related writes:
class Appointment(models.Model):
# ... fields ...
def complete(self):
with transaction.atomic(): # all-or-nothing (transactions lesson)
self.status = self.Status.DONE
self.fee = self._fee_for_patient()
self.save()
Payment.objects.create(appointment=self, amount=self.fee)
def _fee_for_patient(self):
base = self.doctor_standard_fee()
completed = self.patient.appointments.filter(status=self.Status.DONE).count()
return base * Decimal("0.9") if completed >= 5 else base # loyalty discount
The view stays thin — it just calls the method:
def appointment_complete(request, pk):
appt = get_object_or_404(Appointment, pk=pk)
appt.complete()
messages.success(request, "Appointment completed.")
return redirect("appointment_list")
Now "what happens when an appointment completes?" is answered in one place, testable without HTTP (create an
appointment, call complete(), assert), and reusable (a view, a command, an API all call it). The
transaction.atomic guarantees the status change and the payment happen together or not at all — if the
payment creation fails, the status change rolls back (verified: an exception inside atomic rolls back the
whole block). Note: when completing later grows to charge a card and send an SMS (external systems), this
method becomes services.complete_appointment() — the architecture module's boundary.
Recording a test report, with a file upload
Test reports carry an uploaded scan, which brings in the media handling from the static-and-media lesson —
the form needs request.FILES, and the model's FileField stores the file under MEDIA_ROOT:
# views.py
def report_create(request, patient_id):
patient = get_object_or_404(Patient, id=patient_id)
if request.method == "POST":
form = TestReportForm(request.POST, request.FILES) # request.FILES for the upload
if form.is_valid():
report = form.save(commit=False)
report.patient = patient
report.save()
messages.success(request, "Report recorded.")
return redirect("patient_detail", pk=patient.id)
else:
form = TestReportForm()
return render(request, "reports/form.html", {"form": form})
Two details that matter: request.FILES must be passed to the form (or the upload silently arrives empty),
and form.save(commit=False) builds the object without saving so you can set report.patient before the
final save() — the standard pattern when the form does not carry every field. The uploaded scan lands in
MEDIA_ROOT/reports/YYYY/MM/ per the model's upload_to. (The access-control for these sensitive files
comes in the next lesson — for now, the upload works.)
Reporting: the ORM does the summarising
A clinic dashboard needs numbers — appointments per doctor, revenue, tests pending — and these are aggregation queries, not Python loops (the ORM-in-depth module):
from django.db.models import Count, Sum
def dashboard(request):
per_doctor = (
Doctor.objects
.annotate(appointment_count=Count("appointments"),
revenue=Sum("appointments__fee"))
.order_by("-revenue")
)
totals = Appointment.objects.aggregate(
booked=Count("id", filter=Q(status="booked")),
revenue=Sum("fee"),
)
return render(request, "dashboard.html", {"per_doctor": per_doctor, "totals": totals})
Verified earlier: annotate gives per-doctor counts and revenue in one query (Rao 3/2100, Iyer 2/1400), and
conditional Count(..., filter=Q(...)) gets the status breakdown in one query. The dashboard is fast because
the database does the summarising and returns numbers, not rows. This is the difference between a report that
scales and one that loads every appointment into Python to add up.
Check your work
The booking flow. A ModelForm + thin view (POST-redirect-GET, verified 302/200) + the UniqueConstraint
enforcing no double-booking in the database (race-safe), with the view showing a friendly message on the
error.
The list without N+1. select_related("patient", "doctor") makes the list one query, not 1+N (verified
6→1) — every relationship-crossing list gets this by reflex.
Completing an appointment. Behaviour on the model (complete()), wrapped in transaction.atomic so the
status change and payment are all-or-nothing (verified rollback); view stays thin; becomes a service when it
reaches external systems.
Recording a report. request.FILES to the form (or the upload is empty) and form.save(commit=False) to
set the patient before saving; the scan stored under MEDIA_ROOT via upload_to.
Reporting. annotate/aggregate (verified per-doctor and conditional counts) do the summarising in the
database — numbers back, not rows — so the dashboard scales.
Practice
- Build the appointment booking form and thin view; confirm a valid POST creates and redirects, an invalid one re-renders.
- Attempt a double booking (same doctor and slot) and confirm the
UniqueConstraintblocks it; catch the error in the view and show a friendly message. - Build the appointment list with
select_related; with query logging, confirm it is one query, then remove it and watch the count explode. - Implement
Appointment.complete()withtransaction.atomic; test it without HTTP (call the method, assert status/fee/payment), and force the payment to fail to confirm the rollback. - Build the report-create view with a file upload; confirm the scan lands under
MEDIA_ROOTand that omittingrequest.FILESbreaks it. - Build the dashboard with
annotate/aggregate; confirm the per-doctor and status-breakdown numbers, and that it is a handful of queries, not one per doctor.
Official documentation
- Django — Working with forms —
ModelForm,save(commit=False),request.FILES. - Django —
select_related/prefetch_related— Avoiding N+1 on lists. - Django — Aggregation — Dashboard reporting.
Next: the patient API and sensitive-data access.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship