feat: add date_of_birth to patients and users with validation
Merge Request
Overview
Adds date_of_birth support for patients and users. Previously, only patient_age (an integer) was stored, which drifts over time and cannot be trusted. This MR replaces age storage with DOB-based computation, adds full validation, and aligns the backend contract with the frontend.
What does this MR do and why?
-
Motivation: Storing
patient_ageas a static integer is a design flaw — it becomes incorrect every year. The correct approach is to storedate_of_birthand compute age dynamically. -
Approach: Added
date_of_birthcolumn to bothpatientsanduserstables. Age is now always derived from DOB at response time. Input validation is schema-driven (Pydantic), keeping the service layer clean. -
Trade-offs: Existing patients without a DOB will show
patient_age: nulluntil their DOB is updated. The storeduser_agecolumn is preserved for backward compatibility but is no longer written to for patients.
Changes Made
-
app/utils/date_utils.py— New utility:age_from_dob(dob) -> int(single source of truth) -
app/models/patient.py— Addeddate_of_birth,updated_atcolumns -
app/models/user.py— Addeddate_of_birth,is_dob_estimatedcolumns -
app/models/badge.py— Fixed mypy annotation oncriteria_type -
app/schemas/patient.py—date_of_birthrequired on create;patient_ageoutput-only (computed);extra="forbid"onPatientUpdaterejects unknown fields; shared_validate_dobvalidator -
app/schemas/user.py—UserResponsereturnsdate_of_birth+is_dob_estimated -
app/services/patient_service.py— Saves DOB; derives age from DOB; explicitupdated_aton every PATCH -
app/services/user_service.py— Usesage_from_dobfrom utils -
app/services/badge_service.py— Fixed mypy return types -
app/api/v1/routes/auth_routes.py— Signup usesage_from_dobfrom utils -
alembic/versions/a0b1c2d3e4f5_...— Single migration:date_of_birth+updated_aton patients,date_of_birth+is_dob_estimatedon users -
tests/schemas/test_patient_schema.py— DOB validation tests -
tests/test_services/test_patient_service.py— DOB service tests -
tests/test_api/test_patient_routes.py— DOB route tests
Technical Details
Root cause of previous design flaw: patient_age was stored as an integer in the users table. It was never updated after creation, causing it to drift. There was no way to know a patient's actual birthdate.
Fix: date_of_birth is stored on the Patient model. The Patient response schema computes patient_age via a model_validator using age_from_dob(). The service layer no longer writes user_age for patients.
Data flow:
POST /patients/ → PatientCreate validates DOB → Patient.date_of_birth saved
GET /patients/ → Patient schema computes patient_age from date_of_birth
PATCH /patients/ → PatientUpdate validates DOB (optional) → updated_at always set
Validation (schema layer, returns 422):
-
date_of_birthrequired on create - Must be in the past
- Must be within 150 years
-
patient_agerejected in PATCH (extra="forbid")
Type of Change
-
✨ New feature (non-breaking change that adds functionality) -
🐛 Bug fix (non-breaking change that fixes an issue) -
🧪 Test update -
♻ ️ Refactor (no functional changes)
How to Validate Locally
# Run migration
uv run alembic upgrade head
# Get token
TOKEN=$(curl -s -X POST http://localhost:8000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"book_no": 1, "password": "admin123"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
# 1. Create with DOB → age computed
curl -X POST http://localhost:8000/api/v1/patients/ \
-H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-d '{"patient_name":"Ravi Kumar","patient_phone_no":"9000000099","patient_sex":"male","date_of_birth":"1990-06-09"}'
# Expected: patient_age: 35, date_of_birth: "1990-06-09"
# 2. PATCH DOB → age recomputed, updated_at changes
curl -X PATCH http://localhost:8000/api/v1/patients/{bookNo} \
-H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-d '{"date_of_birth":"1992-03-15"}'
# Expected: patient_age: 34, updated_at updated
# 3. Future DOB → 422
curl -X POST http://localhost:8000/api/v1/patients/ \
-H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-d '{"patient_name":"Test","patient_phone_no":"9000000098","patient_sex":"male","date_of_birth":"2100-01-01"}'
# Expected: 422 "date_of_birth must be in the past"
# 4. patient_age in PATCH → 422
curl -X PATCH http://localhost:8000/api/v1/patients/{bookNo} \
-H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-d '{"patient_age":99}'
# Expected: 422 "Extra inputs are not permitted"
Previous behaviour: date_of_birth was not stored. patient_age was a static integer that drifted over time. Sending date_of_birth in requests was silently ignored.
New behaviour: date_of_birth is persisted and returned. patient_age is always computed from DOB. patient_age in PATCH is rejected with 422.
Testing Done
-
Unit tests added/updated -
API endpoint tests passing
Test Cases Covered:
| Scenario | Expected Result | Status |
|---|---|---|
POST with date_of_birth
|
patient_age computed, DOB persisted |
|
POST without date_of_birth
|
422 Field required | |
| POST with future DOB | 422 date_of_birth must be in the past | |
| POST with DOB > 150 years ago | 422 date_of_birth is too far in the past | |
| PATCH with new DOB | age recomputed, updated_at updated |
|
| PATCH name only (no DOB) | succeeds, DOB preserved | |
PATCH with patient_age
|
422 Extra inputs are not permitted | |
| GET patient | returns date_of_birth + computed patient_age
|
|
GET /auth/me
|
returns date_of_birth + is_dob_estimated
|
Test Commands Run:
uv run pytest tests/schemas/test_patient_schema.py tests/test_services/test_patient_service.py tests/test_api/test_patient_routes.py -v
uv run pytest # 1197 passed, 6 skipped
Code Quality Checklist
Code Standards
-
Code follows project conventions (naming, structure, formatting) -
No debug statements or commented-out code left -
No unused imports, variables, or functions -
No duplicate code ( age_from_dobin single utility module) -
Type hints are properly defined — mypy passes -
Ruff checks pass
Python & FastAPI Best Practices
-
Functions follow single-responsibility principle -
Pydantic models used for request/response validation -
Error handling: validation errors return 422, business errors return 400/404
API Design
-
RESTful conventions followed -
Proper HTTP status codes returned (422 for validation, 400 for business logic) -
Input validation implemented at schema layer -
Authentication/authorization enforced
Database & Migrations
-
Single migration created ( a0b1c2d3e4f5) -
Migration is reversible (downgrade script included) -
No raw SQL queries (SQLAlchemy ORM used)
Security
-
No sensitive data logged -
SQL injection prevention verified (ORM used) -
Bandit passes
Known Limitations / Technical Debt
-
user_agecolumn still exists in theuserstable. It is no longer written to for patients but remains for backward compatibility with volunteer/admin signup flow. Can be dropped in a future migration once all user types are migrated to DOB-based age. - Existing patients created before this MR will have
date_of_birth: nulluntil their records are updated via PATCH.
Additional Notes
- The
age_from_dobutility is intentionally placed inapp/utils/date_utils.pyrather than inline in schemas or services to avoid duplication across 3+ files. -
extra="forbid"onPatientUpdateis intentional — it makes the API contract explicit and prevents silent data loss if the frontend accidentally sendspatient_age.