A small full-stack web app to explore healthcare facilities (hospitals, clinics, BHUs, RHCs) in Khyber Pakhtunkhwa (KPK), Pakistan.
Data comes live from OpenStreetMap — via either the healthsites.io API or the Overpass API — through a Django REST API, and is visualised in a React + Leaflet frontend.
- Backend: Django 5, Django REST Framework,
requests,django-cors-headers - Frontend: React (Vite), Tailwind CSS, Leaflet (
react-leaflet) - External data: healthsites.io v3 API or the OpenStreetMap Overpass
API — both OSM-derived and selectable via
FACILITIES_PROVIDER
backend/– Django + DRF projectbackend/settings.py– core Django/DRF, CORS, and cache configurationfacilities/– app that talks to the upstream provider and normalises facility dataDockerfile,gunicorn.conf.py,requirements-prod.txt– production server and image
frontend/– React + Vite + Tailwind single-page appsrc/components/–CitySelect,Filters,FacilityList,MapView,Paginationsrc/lib/– shared helpers: facility-type colours, contact/URL normalisationDockerfile,nginx.conf– static-site image
docker-compose.yml– local stack: API + frontend + Redisdeploy/– AWS deployment guide (S3 + CloudFront, EC2), nginx and systemd examples.github/workflows/ci.yml– CI: backend tests, frontend lint + build, Docker image builds
-
Create and activate a virtualenv (recommended)
cd backend python -m venv .venv # Windows PowerShell .venv\Scripts\Activate.ps1 # or cmd .venv\Scripts\activate.bat
-
Install dependencies
pip install -r requirements.txt
-
Configure environment
Copy the example env file and adjust if needed:
copy .env.example .env # on WindowsKey variables:
DJANGO_SECRET_KEY– any random string for local dev; must be a unique secret whenDJANGO_DEBUGis nottrueDJANGO_DEBUG– off by default; set totruefor local development onlyDJANGO_ALLOWED_HOSTS– usually*for local dev; real host name(s) (no*) otherwiseCORS_ALLOWED_ORIGINS– frontend origin, e.g.http://localhost:5173(required whenDJANGO_DEBUGis nottrue)API_THROTTLE_RATE– per-client rate limit (default60/min); over the limit the API returns429API_NUM_PROXIES– number of reverse proxies in front of Django (so the limit sees the real client IP)FACILITIES_PROVIDER– data source:healthsites(default) oroverpassHEALTHSITES_API_KEY– required when using thehealthsitesprovider. Get one free at healthsites.io: sign in with an OpenStreetMap account, then generate a key from your profile page. Keep it in.env(gitignored) — never commit it.OVERPASS_API_URL– Overpass endpoint (used whenFACILITIES_PROVIDER=overpass)OVERPASS_USER_AGENT– identifies this app to Overpass; public instances 406-reject requests with a generic client User-AgentCITY_SEARCH_RADIUS_METERS– radius (meters) searched around each city's center point (default12000)FACILITIES_CACHE_TTL– cache duration (seconds) for city results
-
Run migrations
python manage.py migrate
-
Run the Django dev server
python manage.py runserver 0.0.0.0:8000
-
API endpoint
GET /api/facilities?city=<city_name>— facilities in a KPK city, orGET /api/facilities?lat=<float>&lon=<float>— near me: facilities around a location
Optional parameters:
Parameter Meaning limitMax results, 1…500(default500)ownershipgovernmentorprivateemergency,open_24_7,wheelchairtrueto keep only facilities with that recordedqText search: every word must appear in the name or address (case-insensitive) sortname(A–Z, unnamed last) ordistance(nearest first)typehospital,clinic,health_unit(BHU/RHC),pharmacy_laborothercityandlat/loncan be combined: the city is searched and distances are measured from the visitor. Withlat/lonthe default order is nearest first.Examples:
curl "http://localhost:8000/api/facilities?city=Peshawar&limit=100&emergency=true" curl "http://localhost:8000/api/facilities?lat=34.0151&lon=71.5249&limit=20" # near me curl "http://localhost:8000/api/facilities?city=Peshawar&q=hospital&sort=name"
The backend:
- Validates
cityagainst the supported KPK list (unknown cities get a400) - Validates
lat/lon: both required together, real numbers, and inside the Khyber Pakhtunkhwa area (anything else gets a400) - Fetches up to 500 facilities for the city (
FETCH_CAP), de-duplicated, and caches that once per(provider, city)using Django'sLocMemCache. For near-me searches the location is snapped to a ~5 km grid first, so the number of upstream calls and cache entries stays bounded however many different GPS readings arrive - Applies the filters and the text search to all fetched facilities, then sorts, then
clamps to
limit— so a filter never misses matches beyond the firstlimit - Rejects bad
ownership/emergency/open_24_7/wheelchair/sortvalues with a400 - Normalises results into a consistent facility shape regardless of provider
- Returns JSON via DRF serializer, plus two response headers:
X-Total-Count– facilities matching the filters (can be larger than the array returned)X-Upstream-Capped–trueif the city hit the 500 cap, so more may exist in OSM
Response shape:
[ { "osm_id": "way/854341089", "name": "CMH Peshawar - Combined Military Hospital", "facility_type": "Hospital", "address": "", "phone": "", "website": "", "email": "", "is_24_7": null, "is_emergency": true, "wheelchair": "", "ownership": "", "distance_km": null, "lat": 34.0034083, "lon": 71.5427023 } ]is_24_7/is_emergencyaretrue,false, ornullwhen OSM records nothing. Empty strings mean the tag is absent upstream.wheelchairis"yes","limited","no"or""(unknown).distance_kmis the distance from the visitor (or from the city centre whensort=distance), andnullwhen no reference point is known.Accessibility data is thin.
wheelchaircomes from the OpenStreetMapwheelchairtag. The healthsites.io responses checked so far do not include it (0 of 117 Peshawar facilities had it), so it is mostly filled in only whenFACILITIES_PROVIDER=overpass, and only where OSM volunteers have mapped it.
-
Install dependencies
cd frontend npm install -
Configure environment
copy .env.example .env
VITE_API_BASE_URL– base URL of the Django backend, e.g.http://localhost:8000
-
Run the dev server
npm run dev
The app will be available at
http://localhost:5173by default.
The UI is a three-pane layout: a search/filter sidebar, a results panel, and the map. A List and map / List / Map toggle in the header controls which panes are shown — List mode hides the map and lays results out as a multi-column grid.
Design ("civic" style): a warm off-white page with near-black text and a single deep
green accent, set in Atkinson Hyperlegible
(designed for low-vision readers; system fonts are the fallback). Body text is 16px or
larger, controls are real form elements (radio buttons, checkboxes, selects), keyboard
focus is clearly outlined, and there is a "Skip to results" link. Colours are defined once
in frontend/tailwind.config.js. Colour is never the only signal: each facility type has a
letter (H, C, B, R) on its list marker and map pin, and emergency / 24-hour status is written
out as text.
CitySelect– dropdown of 34 KPK towns: the main town of each district (Peshawar, Mardan, Abbottabad, Mingora/Swat, Kohat, Bannu, D.I. Khan, Chitral, Dir, Parachinar, Wana, …). Defined once inCITY_CENTERS(backend/facilities/services.py) and mirrored infrontend/src/constants/cities.js; a test fails if the two drift apart. Chitral and Pattan also stand in for their neighbouring districts (Upper Chitral, Kolai-Palas).LocationControl– "Use my location" button (the browser asks for permission only when it is clicked). The position is rounded to ~110 m before it is sent, shown on the map as "You are here", and can be switched off again. Browsers only allow this on HTTPS (or localhost).SearchBox– search by name or address, debounced so it does not request on every keyFilters– type of facility, sort order (default / name / nearest), ownership radio buttons, "Has emergency services", "Open 24 hours" and "Wheelchair accessible" checkboxes, how many results to show, and "Clear all filters". On small screens this panel folds into a "Show filters" button so results are not pushed off-screenFacilityList– cards showing:- name, facility type, ownership and address
- distance ("0.9 km from you", or from the city centre when sorting by distance)
- "Emergency services" / "Open 24 hours" / wheelchair access as plain text when known
- a contact row: tap-to-call phone, website and email when present, plus a Directions link (available for every facility, since it only needs coordinates) and a Show on map button
MapView– Leaflet map (standard OpenStreetMap tiles, desaturated with CSS) with letter-marked pins and a matching key. Auto-fits bounds to the current results, flies to the selected facility, and callsinvalidateSize()on container resize so the view toggle doesn't leave grey tiles.Pagination– Previous / Next and page numbers, showing the current range
The root App component:
- Manages selected city, the visitor's location, search text, filters, result limit, view mode, pagination and selection
- Calls
GET /api/facilitiesvia Axios withcityand/orlat/lon,limit, the search text and the active filters (filtering, search and sorting happen on the server, before the limit) - Handles loading (skeletons), error, and empty states
- Shows a notice when results are cut off (more matches than the limit, or the city hit the 500-facility fetch cap)
- Paginates the loaded results at 20 per page, resetting to page 1 whenever the city or filters change
- Shows "Unnamed " for facilities with no name
- Feeds only the current page to both the list and the map, keeping the two in sync and avoiding overlapping-pin clutter on dense cities
List ↔ map selection is shared: clicking a card or a marker highlights the card and flies the map to that facility with an enlarged marker.
-
facilities/services.py- Supports two interchangeable providers, selected by
FACILITIES_PROVIDER:- healthsites (default) – queries healthsites.io's v3 API with a
bounding box derived from the city center; needs
HEALTHSITES_API_KEY - overpass – queries the Overpass API directly
- healthsites (default) – queries healthsites.io's v3 API with a
bounding box derived from the city center; needs
- Both search within
CITY_SEARCH_RADIUS_METERSof each supported city's known center coordinates (CITY_COORDINATES) rather than doing an OSM administrative boundary lookup, since those boundaries are tagged with local-script (Urdu/Pashto) names in OSM and don't reliably match an English city name - Both normalise through the same
infer_*helpers, so the API response shape is identical regardless of provider - Normalises each element into a facility object:
osm_id(type-qualified and unique, e.g.node/123,way/123),name,facility_type(Hospital,Clinic,BHU,RHC, etc.; BHU/RHC match whole words only)address,phone,website,emailis_24_7,is_emergencyownership(government vs private): explicit operator/ownership tags first, then unmistakable government markers in the name (DHQ, THQ, BHU, RHC, Civil Hospital, …); otherwise left empty rather than guessedlat,lon
- Wraps low-level/network errors into a custom
FacilityProviderError - De-duplicates facilities by
osm_id, and merges records that are the same place mapped twice (same name within 150 m, keeping the most complete one) - Treats names that are only numbers or punctuation (e.g. "82") as unnamed
- Provides
filter_facilities, text search, sorting, distance and the near-me grid snapping - Uses Django cache (
LocMemCache) keyed by(provider, city)so switching providers doesn't serve stale results from the other one
- Supports two interchangeable providers, selected by
-
facilities/views.py- DRF
APIView(FacilityListView) bound toGET /api/facilities - Validates query parameters (
cityagainst the supported list, optionallimitclamped toMAX_LIMIT, optionalownership/emergency/open_24_7filters) - Delegates to the service layer, filters, applies the limit and serialises results via
FacilitySerializer; sets theX-Total-Count/X-Upstream-Cappedheaders - Returns clean error responses (400 for bad input, 502 for upstream provider failures)
- DRF
For local development (React at localhost:5173, Django at localhost:8000):
-
With
DJANGO_DEBUG=true, all origins are allowed unless you setCORS_ALLOWED_ORIGINS. -
To restrict it locally, set in
backend/.env:CORS_ALLOWED_ORIGINS=http://localhost:5173
Outside DJANGO_DEBUG=true, CORS_ALLOWED_ORIGINS is required and the app refuses to start without it.
# Backend (from backend/, with the virtualenv active)
python manage.py test # unit and API tests
python manage.py check --deploy # audit the production configuration (set the production env vars first)
# Frontend (from frontend/)
npm run lint # ESLint (React + hooks rules)
npm run buildGitHub Actions (.github/workflows/ci.yml) runs the backend tests, the frontend lint and
build, and builds both Docker images on every push to main and every pull request.
Health check: GET /api/health returns {"status": "ok"}. It never calls an upstream
provider and is not rate-limited, so it is safe for load balancers and uptime monitors.
Logging: logs go to the console (LOG_LEVEL, default INFO). Upstream failures are logged
by type only, never with request URLs, so the API key cannot end up in the logs.
Shared cache: by default results are cached in each process's memory. Set REDIS_URL
(e.g. an ElastiCache endpoint) to share one cache across gunicorn workers and servers. If the
cache is unreachable the API keeps working (slower, with a logged warning).
cp backend/.env.example backend/.env # put your HEALTHSITES_API_KEY in it
docker compose up --buildFrontend at http://localhost:8080, API health at http://localhost:8000/api/health. The compose
file also starts Redis and points the API at it. Secrets are never baked into the images
(.env is excluded by .dockerignore); they are passed at runtime. The frontend image bakes in
VITE_API_BASE_URL at build time (see the build arg in docker-compose.yml).
See deploy/README.md for the S3 + CloudFront (frontend) and EC2 (API) setup, including nginx and systemd examples, the production settings, and TLS.
- KPK‑only city dropdown, validated server-side as well as in the UI
- Facilities fetched dynamically from OSM data, not hard-coded
- Two interchangeable upstream providers (healthsites.io / Overpass)
- REST endpoint:
GET /api/facilitiesby city or by location, with filters, text search and sorting - Basic in‑memory caching via Django cache framework (
LocMemCache) - React SPA with:
- responsive layout (mobile + desktop) with List-and-map / List / Map view modes
- cards list + interactive Leaflet map, linked by a shared selection
- client-side pagination at 20 per page
- "Use my location" near-me search, name/address search, sorting, and server-side filters (type, ownership, emergency, 24/7, wheelchair), plus a notice when results are cut off
- contact details (phone / website / email) plus Directions for every facility
- letter-marked map pins with a key, auto-fitted to results
- loading skeletons, empty, and error states
- Env‑driven configuration for both backend and frontend
- OSM data completeness: Only facilities mapped in OpenStreetMap will appear. Many BHUs/RHCs and private clinics may be missing or outdated.
- Contact data is sparse: this is the biggest practical limitation. In a sample of 88 Peshawar facilities, only 8 had a phone number (~9%), 3 had a website and 2 an email. "No phone listed" is therefore the common case, not a bug. The Directions link is provided precisely because coordinates are the one field that is always present.
- City search is radius-based: results come from a
CITY_SEARCH_RADIUS_METERSradius around hard-coded city centre coordinates, not true administrative boundaries. OSM tags KPK boundaries with local-script (Urdu/Pashto) names, so matching them by English name does not work. A facility just outside the radius will be missed. - healthsites API keys need manual approval: keys are issued inactive and only start
working once a healthsites admin approves the enrolment (they email you). Until then the
API returns
403and you should stay onFACILITIES_PROVIDER=overpass. - Public Overpass instances are unreliable: they frequently return
504s under load. The default is OSM France's mirror, which benchmarked far better thanoverpass-api.de. A descriptiveOVERPASS_USER_AGENTis mandatory — generic agents get406-rejected. - No authentication: This is a public, read-only discovery tool.
- No write operations: The app does not modify OpenStreetMap or store data permanently.
- Not a replacement for Google Maps: Routing, turn‑by‑turn navigation, and rich POI data are out of scope for this MVP.