RizTech Academy logo
RizTech Academy
Capstone: Nidaan, end to endLesson 3 of 545 min

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

  1. Build the appointment booking form and thin view; confirm a valid POST creates and redirects, an invalid one re-renders.
  2. Attempt a double booking (same doctor and slot) and confirm the UniqueConstraint blocks it; catch the error in the view and show a friendly message.
  3. Build the appointment list with select_related; with query logging, confirm it is one query, then remove it and watch the count explode.
  4. Implement Appointment.complete() with transaction.atomic; test it without HTTP (call the method, assert status/fee/payment), and force the payment to fail to confirm the rollback.
  5. Build the report-create view with a file upload; confirm the scan lands under MEDIA_ROOT and that omitting request.FILES breaks it.
  6. 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

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