Skip to content

feat: add date_of_birth to patients and users with validation

Suma Pullaiahgari requested to merge ehrs-dob into develop

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_age as a static integer is a design flaw — it becomes incorrect every year. The correct approach is to store date_of_birth and compute age dynamically.
  • Approach: Added date_of_birth column to both patients and users tables. 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: null until their DOB is updated. The stored user_age column 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 — Added date_of_birth, updated_at columns
  • app/models/user.py — Added date_of_birth, is_dob_estimated columns
  • app/models/badge.py — Fixed mypy annotation on criteria_type
  • app/schemas/patient.pydate_of_birth required on create; patient_age output-only (computed); extra="forbid" on PatientUpdate rejects unknown fields; shared _validate_dob validator
  • app/schemas/user.pyUserResponse returns date_of_birth + is_dob_estimated
  • app/services/patient_service.py — Saves DOB; derives age from DOB; explicit updated_at on every PATCH
  • app/services/user_service.py — Uses age_from_dob from utils
  • app/services/badge_service.py — Fixed mypy return types
  • app/api/v1/routes/auth_routes.py — Signup uses age_from_dob from utils
  • alembic/versions/a0b1c2d3e4f5_... — Single migration: date_of_birth + updated_at on patients, date_of_birth + is_dob_estimated on 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_birth required on create
  • Must be in the past
  • Must be within 150 years
  • patient_age rejected 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_dob in 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_age column still exists in the users table. 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: null until their records are updated via PATCH.

Additional Notes

  • The age_from_dob utility is intentionally placed in app/utils/date_utils.py rather than inline in schemas or services to avoid duplication across 3+ files.
  • extra="forbid" on PatientUpdate is intentional — it makes the API contract explicit and prevents silent data loss if the frontend accidentally sends patient_age.

Merge request reports

Loading