EthicsComputerCourses
Join training +91 90532 10052

Project: A Clinic Booking API

Django & Flask · Lesson 42 of 42 Advanced 10 min read Updated

This project puts the whole Advanced Django section to work in the kind of codebase you would meet in a real job. Kochi Care Clinic has two doctors and wants a mobile app where patients see free 15-minute slots, book one, get an SMS confirmation and cancel up to two hours before. Reception staff must see and manage everything. The clinic's biggest fear is simple: two patients booked into the same slot.

You will build the back end as a JSON API. The work is split the way a team would split it: domain apps, a settings package, models protected by database constraints, service functions with row locks, read-only endpoints with caching, an appointments endpoint with object permissions, JWT login, OpenAPI docs, a management command, a background task, and a pytest suite. Every output below comes from running the finished project with Django 6.1, DRF 3.18, Simple JWT 5.5 and drf-spectacular 0.30.

What we are building

EndpointWhoWhat it does
POST /api/token/EveryoneUsername and password in, JWT access and refresh tokens out
GET /api/doctors/EveryoneDoctors with speciality and fee (cached)
GET /api/slots/?doctor=1&starts_at__date=2026-11-03EveryoneFuture slots that are still free
POST /api/appointments/Logged-in patientsBook a slot; sends an SMS after commit
GET /api/appointments/Patients: their own; staff: allList appointments
POST /api/appointments/<id>/cancel/Owner (2+ hours before) or staff (any time)Cancel
GET /api/docs/DevelopersSwagger UI from the OpenAPI schema

Business rules: a slot has at most one active booking; nobody books a slot in the past; a patient has at most one appointment per doctor per day; patients cancel at least two hours before, while reception can always cancel.

Step 1: Project, settings and apps

Create the project as in Structuring Large Django Projects: a venv, django-admin startproject config ., then three domain apps:

Terminal
python -m pip install django djangorestframework djangorestframework-simplejwt drf-spectacular django-filter pytest pytest-django factory-boy
python manage.py startapp accounts      # custom User, created before the first migrate
python manage.py startapp doctors       # Doctor, Slot
python manage.py startapp bookings      # Appointment, booking rules

Move settings.py into config/settings/ with base.py, dev.py, test.py and prod.py. The important lines of base.py:

Python
# config/settings/base.py (excerpt)
INSTALLED_APPS = [
    # ... Django's contrib apps
    "rest_framework",
    "rest_framework_simplejwt.token_blacklist",
    "django_filters",
    "drf_spectacular",
    "accounts",
    "doctors",
    "bookings",
]
AUTH_USER_MODEL = "accounts.User"
TIME_ZONE = "Asia/Kolkata"
USE_TZ = True

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework_simplejwt.authentication.JWTAuthentication"],
    "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
    "PAGE_SIZE": 20,
    "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}}
CANCEL_HOURS = 2

The default permission is IsAuthenticated, so every new endpoint is closed until you open it on purpose. That is the safer default for a medical app. test.py adds the fast password hasher and switches TASKS to the DummyBackend, so tests never send anything.

Step 2: Models with database guarantees

Python
# accounts/models.py
class User(AbstractUser):
    phone = models.CharField(max_length=10, blank=True)


# doctors/models.py
class Doctor(models.Model):
    user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
                                related_name="doctor")
    name = models.CharField(max_length=100)
    speciality = models.CharField(max_length=60)
    fee = models.PositiveIntegerField(help_text="Consultation fee in rupees")

    def __str__(self):
        return f"Dr. {self.name} ({self.speciality})"


class Slot(models.Model):
    doctor = models.ForeignKey(Doctor, on_delete=models.CASCADE, related_name="slots")
    starts_at = models.DateTimeField()
    minutes = models.PositiveSmallIntegerField(default=15)

    class Meta:
        ordering = ["starts_at"]
        constraints = [
            models.UniqueConstraint(fields=["doctor", "starts_at"], name="one_slot_per_doctor_time"),
        ]


# bookings/models.py
class Appointment(models.Model):
    class Status(models.TextChoices):
        BOOKED = "booked", "Booked"
        CANCELLED = "cancelled", "Cancelled"

    slot = models.ForeignKey("doctors.Slot", on_delete=models.PROTECT, related_name="appointments")
    patient = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
                                related_name="appointments")
    reason = models.CharField(max_length=200, blank=True)
    status = models.CharField(max_length=10, choices=Status, default=Status.BOOKED)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["slot"], condition=models.Q(status="booked"),
                name="one_active_booking_per_slot"),
        ]

The last constraint is the heart of the project. It is a conditional unique constraint: among rows with status="booked", each slot may appear only once, while any number of cancelled rows may share a slot. Cancelled appointments are kept, not deleted, because a clinic needs that history. Even if a future bug skips every Python check, the database refuses:

Output
IntegrityError: UNIQUE constraint failed: bookings_appointment.slot_id

on_delete=models.PROTECT stops anyone deleting a slot or patient that has appointments. Run makemigrations and migrate.

Step 3: The booking rules as services

All rules live in bookings/services.py, following Transactions & Race Conditions:

Python
# bookings/services.py
class BookingError(Exception):
    pass


@transaction.atomic
def book_slot(*, patient, slot_id: int, reason: str = "") -> Appointment:
    slot = Slot.objects.select_for_update().select_related("doctor").get(pk=slot_id)
    if slot.starts_at <= timezone.now():
        raise BookingError("This slot has already started.")
    if Appointment.objects.filter(slot=slot, status=Appointment.Status.BOOKED).exists():
        raise BookingError("This slot is already booked.")
    local_day = timezone.localtime(slot.starts_at).date()
    same_day = Appointment.objects.filter(
        patient=patient, status=Appointment.Status.BOOKED,
        slot__doctor=slot.doctor, slot__starts_at__date=local_day)
    if same_day.exists():
        raise BookingError("You already have an appointment with this doctor that day.")
    try:
        with transaction.atomic():
            appt = Appointment.objects.create(slot=slot, patient=patient, reason=reason)
    except IntegrityError:                      # lost a race on a database without row locks
        raise BookingError("This slot is already booked.")
    transaction.on_commit(partial(send_booking_sms.enqueue, appt.pk))
    logger.info("Appointment %s booked: slot %s, patient %s", appt.pk, slot.pk, patient.pk)
    return appt


@transaction.atomic
def cancel_appointment(*, appointment: Appointment, by) -> Appointment:
    appt = Appointment.objects.select_for_update().select_related("slot").get(pk=appointment.pk)
    if appt.status == Appointment.Status.CANCELLED:
        raise BookingError("Already cancelled.")
    limit = appt.slot.starts_at - timedelta(hours=settings.CANCEL_HOURS)
    if not by.is_staff and timezone.now() > limit:
        raise BookingError(f"Cancel at least {settings.CANCEL_HOURS} hours before; call the desk.")
    appt.status = Appointment.Status.CANCELLED
    appt.save(update_fields=["status"])
    logger.info("Appointment %s cancelled by user %s", appt.pk, by.pk)
    return appt

Three layers protect the slot. select_for_update() makes a second booking for the same slot wait on PostgreSQL or MySQL until the first commits, then see it and refuse politely. The database constraint catches anything that still gets through, and the inner atomic() savepoint turns that IntegrityError into the same friendly error. The SMS is queued with on_commit(), so a rolled-back booking never sends one. Note timezone.localtime(): stored times are UTC, and "that day" must mean the day in Kochi, not in London.

The task itself is small and receives only the ID, as in Background Tasks:

Python
# bookings/tasks.py
@task
def send_booking_sms(appointment_id: int) -> str:
    appt = Appointment.objects.select_related("slot__doctor", "patient").get(pk=appointment_id)
    when = timezone.localtime(appt.slot.starts_at)
    text = f"Confirmed: {appt.slot.doctor} on {when:%d %b at %H:%M}. Kochi Care Clinic"
    # A real project calls the SMS provider here.
    return f"to {appt.patient.phone}: {text}"

Step 4: Read endpoints with a selector and a cache

Free slots are a read query used by the API now and maybe by a reception screen later, so they go in a selector:

Python
# doctors/selectors.py
def open_slots():
    """Future slots without an active booking."""
    taken = Appointment.objects.filter(slot=OuterRef("pk"), status=Appointment.Status.BOOKED)
    return (Slot.objects.select_related("doctor")
            .filter(starts_at__gt=timezone.now())
            .exclude(Exists(taken)))


# doctors/api.py
@method_decorator(cache_page(60 * 10), name="list")
class DoctorViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = Doctor.objects.order_by("name")
    serializer_class = DoctorSerializer
    permission_classes = [permissions.AllowAny]


class OpenSlotViewSet(viewsets.ReadOnlyModelViewSet):
    serializer_class = SlotSerializer                # doctor shown with StringRelatedField
    permission_classes = [permissions.AllowAny]
    filterset_fields = {"doctor": ["exact"], "starts_at": ["date"]}

    def get_queryset(self):
        return open_slots()

Exists() from Advanced ORM removes booked slots in the same SQL query. The doctors list changes rarely and is the same for everyone, so cache_page keeps it for ten minutes (Caching); slots change with every booking, so they are never cached. filterset_fields as a dictionary allows the starts_at__date lookup in the URL.

Step 5: The appointments endpoint

Python
# bookings/api.py
class IsPatientOrStaff(permissions.BasePermission):
    message = "This appointment belongs to another patient."

    def has_object_permission(self, request, view, obj):
        return request.user.is_staff or obj.patient_id == request.user.id


class AppointmentSerializer(serializers.ModelSerializer):
    slot_id = serializers.IntegerField(write_only=True)
    doctor = serializers.CharField(source="slot.doctor", read_only=True)
    starts_at = serializers.DateTimeField(source="slot.starts_at", read_only=True)

    class Meta:
        model = Appointment
        fields = ["id", "slot_id", "doctor", "starts_at", "reason", "status"]
        read_only_fields = ["status"]


class AppointmentViewSet(mixins.CreateModelMixin, mixins.ListModelMixin,
                         mixins.RetrieveModelMixin, viewsets.GenericViewSet):
    serializer_class = AppointmentSerializer
    permission_classes = [permissions.IsAuthenticated, IsPatientOrStaff]

    def get_queryset(self):
        if getattr(self, "swagger_fake_view", False):
            return Appointment.objects.none()
        appts = Appointment.objects.select_related("slot__doctor").order_by("slot__starts_at")
        if not self.request.user.is_staff:
            appts = appts.filter(patient=self.request.user)
        return appts

    def perform_create(self, serializer):
        try:
            serializer.instance = book_slot(
                patient=self.request.user,
                slot_id=serializer.validated_data["slot_id"],
                reason=serializer.validated_data.get("reason", ""))
        except BookingError as error:
            raise serializers.ValidationError({"detail": str(error)})

    @extend_schema(request=None, responses={200: AppointmentSerializer})
    @action(detail=True, methods=["post"])
    def cancel(self, request, pk=None):
        try:
            appt = cancel_appointment(appointment=self.get_object(), by=request.user)
        except BookingError as error:
            return Response({"detail": str(error)}, status=status.HTTP_400_BAD_REQUEST)
        return Response(self.get_serializer(appt).data)

The viewset has no update or delete actions at all: appointments change only through the cancel action, which calls the service. Because get_queryset() hides other patients' rows, a patient asking for someone else's appointment gets 404 Not Found, which does not even reveal that it exists. IsPatientOrStaff is a second guard, for the day someone changes get_queryset().

Step 6: Login, docs and URLs

Python
# config/urls.py
router = DefaultRouter()
router.register("doctors", DoctorViewSet)
router.register("slots", OpenSlotViewSet, basename="slot")
router.register("appointments", AppointmentViewSet, basename="appointment")

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/token/", TokenObtainPairView.as_view(), name="token"),
    path("api/token/refresh/", TokenRefreshView.as_view(), name="token-refresh"),
    path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
    path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="docs"),
    path("api/", include(router.urls)),
]

SIMPLE_JWT uses a 15-minute access token, a 7-day refresh token and rotation with the blacklist, exactly as in JWT Auth & OpenAPI Docs. Generate the schema and check it:

Terminal
python manage.py spectacular --file schema.yml --validate
grep -E "^  /api" schema.yml
Output
/api/appointments/:
/api/appointments/{id}/:
/api/appointments/{id}/cancel/:
/api/doctors/:
/api/doctors/{id}/:
/api/slots/:
/api/slots/{id}/:
/api/token/:
/api/token/refresh/:

No warnings: the swagger_fake_view check and @extend_schema on cancel took care of the two usual ones.

Step 7: A command to create slots

Reception should not type 72 slots by hand. A management command creates 15-minute slots from 10:00 to 13:00 for every doctor, skipping Sundays:

Python
# doctors/management/commands/make_slots.py (core of handle())
tz = timezone.get_current_timezone()
first = timezone.localdate() + timedelta(days=1)
new = []
for doctor in Doctor.objects.all():
    for d in range(days):
        day = first + timedelta(days=d)
        if day.weekday() == 6:          # clinic closed on Sundays
            continue
        t = datetime.combine(day, start_t, tz)
        while t.time() < end_t:
            new.append(Slot(doctor=doctor, starts_at=t))
            t += timedelta(minutes=15)
before = Slot.objects.count()
Slot.objects.bulk_create(new, ignore_conflicts=True)     # skip slots that already exist
added = Slot.objects.count() - before
self.stdout.write(self.style.SUCCESS(f"Added {added} new slots ({len(new)} checked)."))
Terminal
python manage.py make_slots --days 3
python manage.py make_slots --days 3
Output
Added 72 new slots (72 checked).
Added 0 new slots (72 checked).

Running it twice is safe: the one_slot_per_doctor_time constraint plus ignore_conflicts=True skips duplicates, so the command can run every night from a scheduler. (The count is taken before and after, because bulk_create() with ignore_conflicts returns every object you passed, not only the inserted ones.)

Step 8: Try the whole flow

With two doctors, patients Ananya and Kabir, and a staff user reception, a script logs in through /api/token/ and calls the API like the mobile app would:

Output
GET /api/doctors/                        200 {"count": 2, ..., "results": [{"id": 2, "name": "Farhan Ali", "speciality": "Paediatrics", "fee": 500}, {"id": 1, "name": "Meera Nair", "speciality": "General Physician", "fee": 400}]}
GET /api/slots/?doctor=1&date&limit=2    200 {"count": 12, ..., "results": [{"id": 1, "doctor": "Dr. Meera Nair (General Physician)", "starts_at": "2026-11-03T10:00:00+05:30", "minutes": 15}, ...]}
POST appointment (no token)              401 {"detail": "Authentication credentials were not provided."}
POST appointment ananya                  201 {"id": 1, "doctor": "Dr. Meera Nair (General Physician)", "starts_at": "2026-11-03T10:00:00+05:30", "reason": "Fever for 3 days", "status": "booked"}
POST same slot kabir                     400 {"detail": "This slot is already booked."}
POST 2nd slot same doctor/day ananya     400 {"detail": "You already have an appointment with this doctor that day."}
GET slots count after booking            200 {"count": 11, ...}
GET /api/appointments/1/ as kabir        404 {"detail": "No Appointment matches the given query."}
POST cancel as ananya                    200 {..., "status": "cancelled"}
POST cancel again                        400 {"detail": "Already cancelled."}
POST same slot kabir (after cancel)      201 {"id": 2, "doctor": "Dr. Meera Nair (General Physician)", "starts_at": "2026-11-03T10:00:00+05:30", "reason": "", "status": "booked"}

Times come back with +05:30 because TIME_ZONE = "Asia/Kolkata", so the app shows Indian time without guessing. After the cancel, the conditional constraint allowed Kabir to take the slot, and the history row for Ananya is still there. The development run also printed a warning from the JWT library that the HMAC key was only 19 bytes: the placeholder dev secret is too short. Production must use a long random DJANGO_SECRET_KEY.

Step 9: Tests for the money paths

Factories (as in Testing in Depth) make each test a few lines:

Python
# tests/test_booking.py
pytestmark = pytest.mark.django_db


def test_slot_cannot_be_booked_twice():
    slot = SlotFactory()
    book_slot(patient=UserFactory(), slot_id=slot.pk)
    with pytest.raises(BookingError, match="already booked"):
        book_slot(patient=UserFactory(), slot_id=slot.pk)


def test_past_slot_is_refused():
    slot = SlotFactory(starts_at=timezone.now() - timedelta(minutes=5))
    with pytest.raises(BookingError, match="already started"):
        book_slot(patient=UserFactory(), slot_id=slot.pk)


@pytest.mark.parametrize("hours_before, is_staff, allowed", [
    (5, False, True), (1, False, False), (1, True, True),
])
def test_cancel_window(hours_before, is_staff, allowed):
    appt = AppointmentFactory(slot__starts_at=timezone.now() + timedelta(hours=hours_before))
    user = UserFactory(is_staff=True) if is_staff else appt.patient
    if allowed:
        assert cancel_appointment(appointment=appt, by=user).status == "cancelled"
    else:
        with pytest.raises(BookingError, match="at least 2 hours"):
            cancel_appointment(appointment=appt, by=user)


def test_sms_task_queued_after_commit(django_capture_on_commit_callbacks):
    default_task_backend.clear()
    with django_capture_on_commit_callbacks(execute=True):
        appt = book_slot(patient=UserFactory(), slot_id=SlotFactory().pk)
    assert [r.args for r in default_task_backend.results] == [[appt.pk]]


def test_patients_see_only_their_appointments(django_assert_num_queries):
    mine = AppointmentFactory()
    AppointmentFactory.create_batch(3)
    client = APIClient()
    client.force_authenticate(mine.patient)
    with django_assert_num_queries(2):
        data = client.get("/api/appointments/").json()
    assert [a["id"] for a in data["results"]] == [mine.pk]
Output
tests/test_booking.py::test_slot_cannot_be_booked_twice PASSED           [ 14%]
tests/test_booking.py::test_past_slot_is_refused PASSED                  [ 28%]
tests/test_booking.py::test_cancel_window[5-False-True] PASSED           [ 42%]
tests/test_booking.py::test_cancel_window[1-False-False] PASSED          [ 57%]
tests/test_booking.py::test_cancel_window[1-True-True] PASSED            [ 71%]
tests/test_booking.py::test_sms_task_queued_after_commit PASSED          [ 85%]
tests/test_booking.py::test_patients_see_only_their_appointments PASSED  [100%]

============================== 7 passed in 5.64s ==============================

AppointmentFactory(slot__starts_at=...) passes a value through the SubFactory to the slot, a handy factory_boy feature. With the DummyBackend, default_task_backend.results records each queued job without running it, so the SMS test checks the queue, not a phone.

Step 10: Ship it

The production steps are the ones from Deployment and Docker, Logging & Monitoring: PostgreSQL instead of SQLite (where select_for_update() really locks), Redis for the cache and task broker, a worker process for the SMS task backend, Gunicorn behind Nginx, JSON logs with request IDs, /healthz/, and python manage.py check --deploy before every release. Health data is sensitive personal data: log appointment IDs, never reasons or phone numbers, restrict the admin to staff, and decide with the clinic how long cancelled history is kept, in line with India's DPDP Act.

Ideas to extend it

  • Doctors log in and see their day's list (GET /api/appointments/?date= filtered to request.user.doctor).
  • A reminder task the evening before each appointment, scheduled with Celery beat.
  • Online payment of the fee before the booking is confirmed, with a "pending" status that expires after 10 minutes.
  • A WebSocket feed for the reception screen with Django Channels, updating as patients book.
  • Rate limits on /api/token/ with a scoped throttle to slow down password guessing.

Key points

  • Split the project into domain apps (accounts, doctors, bookings) with a settings package and a custom user from day one.
  • Protect the core rule three times: row locks with select_for_update(), checks in a service, and a conditional UniqueConstraint in the database.
  • Keep business rules in services; views and serializers only translate HTTP to service calls and errors to 400 responses.
  • Hide other users' rows in get_queryset() and add an object permission as a second guard.
  • Queue side effects with on_commit(), cache only data that rarely changes, and store and return times as time-zone-aware values.
  • Test the money paths with factories, parametrized cases, query counts and a dummy task backend.

Exercise

Add rescheduling. Write a service reschedule_appointment(*, appointment, new_slot_id, by) that cancels the old appointment and books the new slot for the same patient in one transaction, so that if the new slot is taken, the old booking stays exactly as it was. Write two tests: a successful move, and a failed move that leaves the original appointment booked.

Show solution
Python
# bookings/services.py
@transaction.atomic
def reschedule_appointment(*, appointment: Appointment, new_slot_id: int, by) -> Appointment:
    cancel_appointment(appointment=appointment, by=by)
    return book_slot(patient=appointment.patient, slot_id=new_slot_id, reason=appointment.reason)


# tests/test_reschedule.py
def test_reschedule_moves_the_booking():
    appt = AppointmentFactory(slot__starts_at=timezone.now() + timedelta(days=1))
    new_slot = SlotFactory(doctor=appt.slot.doctor, starts_at=appt.slot.starts_at + timedelta(days=1))
    new = reschedule_appointment(appointment=appt, new_slot_id=new_slot.pk, by=appt.patient)
    appt.refresh_from_db()
    assert (appt.status, new.slot_id, new.status) == ("cancelled", new_slot.pk, "booked")


def test_failed_reschedule_keeps_the_old_booking():
    appt = AppointmentFactory()
    taken = AppointmentFactory(slot__doctor=appt.slot.doctor).slot
    with pytest.raises(BookingError, match="already booked"):
        reschedule_appointment(appointment=appt, new_slot_id=taken.pk, by=appt.patient)
    appt.refresh_from_db()
    assert appt.status == "booked"
    assert Appointment.objects.filter(status="booked").count() == 2
Output
.........                                                                [100%]
9 passed in 3.40s

The outer @transaction.atomic wraps both services: cancel_appointment() and book_slot() become savepoints inside it. When book_slot() raises, the exception leaves the outer block too, so the cancellation is rolled back and the patient keeps the original slot.

Quick Quiz

1. Why does Appointment use a UniqueConstraint on slot with condition=Q(status="booked") instead of unique=True on the field?

2. A patient requests /api/appointments/7/, which belongs to someone else. What does the API return, and why?

3. Why is the confirmation SMS queued with transaction.on_commit()?

4. Why is the doctors list cached but the open-slots list is not?

Learn Django & Flask with a mentor

Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call