Project: A Clinic Booking API
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
| Endpoint | Who | What it does |
|---|---|---|
POST /api/token/ | Everyone | Username and password in, JWT access and refresh tokens out |
GET /api/doctors/ | Everyone | Doctors with speciality and fee (cached) |
GET /api/slots/?doctor=1&starts_at__date=2026-11-03 | Everyone | Future slots that are still free |
POST /api/appointments/ | Logged-in patients | Book a slot; sends an SMS after commit |
GET /api/appointments/ | Patients: their own; staff: all | List appointments |
POST /api/appointments/<id>/cancel/ | Owner (2+ hours before) or staff (any time) | Cancel |
GET /api/docs/ | Developers | Swagger 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:
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 rulesMove settings.py into config/settings/ with base.py, dev.py, test.py and prod.py. The important lines of base.py:
# 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 = 2The 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
# 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:
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:
# 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 apptThree 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:
# 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:
# 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
# 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
# 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:
python manage.py spectacular --file schema.yml --validate
grep -E "^ /api" schema.yml/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:
# 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)."))python manage.py make_slots --days 3
python manage.py make_slots --days 3Added 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:
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:
# 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]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 torequest.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 conditionalUniqueConstraintin the database. - Keep business rules in services; views and serializers only translate HTTP to service calls and errors to
400responses. - 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
# 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......... [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?
The condition limits uniqueness to booked rows only.
2. A patient requests /api/appointments/7/, which belongs to someone else. What does the API return, and why?
Filtering the queryset hides the row entirely, which also avoids revealing that it exists.
3. Why is the confirmation SMS queued with transaction.on_commit()?
Side effects should happen only after the data is safely committed.
4. Why is the doctors list cached but the open-slots list is not?
Cache data that is read often and changes rarely; stale free-slot data would cause failed bookings.
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
