Fix CI lint + format + pinpoint pip-audit exception
Browse filesRuff findings cleaned up:
- Unused imports dropped (base64 in patient.py, HTTPException in
terminology.py, PageBreak in reports.py, alembic's op/sa in the
empty baseline migration)
- asyncio.TimeoutError -> builtin TimeoutError (pdf_extractor)
- Import blocks reorganized per isort in history.py, db.py, and the
security_headers middleware
- try/except/pass in Sentry's before_send hook now logs the filter
error at debug level instead of swallowing silently (S110)
- 10 files passed through `ruff format` for style consistency
ruff.toml: alembic/versions/ added to per-file-ignores. Autogenerated
migrations ship with a fixed `Union[str, Sequence[str], None]` template
and other patterns ruff flags; linting the template breaks autogenerate.
CI pip-audit workflow: explicitly --ignore-vuln CVE-2026-1839 with a
documented justification β the vulnerability is in Transformers'
Trainer._load_rng_state which unpickles external .pth files during
training. DepScreen is inference-only and never uses Trainer or loads
external checkpoints. The fix lands in transformers 5.0.0rc3, a
pre-release; ignore stays until 5.0.0 stable ships.
Verified locally: ruff check + ruff format --check + backend imports
all clean after changes.
- .github/workflows/ci.yml +9 -4
- alembic/versions/72deba99af9e_reconcile_deployed_schema_with_current_.py +6 -9
- app/api/routes/dashboard.py +42 -31
- app/api/routes/history.py +2 -3
- app/api/routes/patient.py +26 -23
- app/api/routes/terminology.py +1 -1
- app/api/routes/webhooks.py +15 -17
- app/core/sentry.py +5 -3
- app/middleware/security_headers.py +1 -7
- app/models/db.py +2 -1
- app/services/avatar.py +3 -6
- app/services/email.py +3 -1
- app/services/pdf_extractor.py +3 -7
- app/services/reports.py +129 -100
- ruff.toml +4 -0
|
@@ -83,10 +83,15 @@ jobs:
|
|
| 83 |
|
| 84 |
- name: Audit backend requirements
|
| 85 |
# --desc: show a one-line description of each CVE
|
| 86 |
-
# --ignore-vuln:
|
| 87 |
-
#
|
| 88 |
-
#
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
docker-build:
|
| 92 |
name: Docker Build
|
|
|
|
| 83 |
|
| 84 |
- name: Audit backend requirements
|
| 85 |
# --desc: show a one-line description of each CVE
|
| 86 |
+
# --ignore-vuln: deliberately ignored CVEs with justification:
|
| 87 |
+
# CVE-2026-1839 (transformers) β affects Trainer._load_rng_state,
|
| 88 |
+
# only reachable when loading malicious rng_state.pth checkpoints
|
| 89 |
+
# during training. We do inference-only, never Trainer, never
|
| 90 |
+
# deserialize external .pth files. Fix is in 5.0.0rc3 (pre-release);
|
| 91 |
+
# re-evaluate when 5.0.0 ships stable.
|
| 92 |
+
run: |
|
| 93 |
+
pip-audit --requirement requirements.txt --desc --strict \
|
| 94 |
+
--ignore-vuln CVE-2026-1839
|
| 95 |
|
| 96 |
docker-build:
|
| 97 |
name: Docker Build
|
|
@@ -1,21 +1,18 @@
|
|
| 1 |
"""reconcile deployed schema with current models (initial baseline)
|
| 2 |
|
| 3 |
Revision ID: 72deba99af9e
|
| 4 |
-
Revises:
|
| 5 |
Create Date: 2026-04-15 13:52:00.764298
|
| 6 |
|
| 7 |
"""
|
| 8 |
-
from typing import Sequence, Union
|
| 9 |
-
|
| 10 |
-
from alembic import op
|
| 11 |
-
import sqlalchemy as sa
|
| 12 |
|
|
|
|
| 13 |
|
| 14 |
# revision identifiers, used by Alembic.
|
| 15 |
-
revision: str =
|
| 16 |
-
down_revision:
|
| 17 |
-
branch_labels:
|
| 18 |
-
depends_on:
|
| 19 |
|
| 20 |
|
| 21 |
def upgrade() -> None:
|
|
|
|
| 1 |
"""reconcile deployed schema with current models (initial baseline)
|
| 2 |
|
| 3 |
Revision ID: 72deba99af9e
|
| 4 |
+
Revises:
|
| 5 |
Create Date: 2026-04-15 13:52:00.764298
|
| 6 |
|
| 7 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
+
from collections.abc import Sequence
|
| 10 |
|
| 11 |
# revision identifiers, used by Alembic.
|
| 12 |
+
revision: str = "72deba99af9e"
|
| 13 |
+
down_revision: str | Sequence[str] | None = None
|
| 14 |
+
branch_labels: str | Sequence[str] | None = None
|
| 15 |
+
depends_on: str | Sequence[str] | None = None
|
| 16 |
|
| 17 |
|
| 18 |
def upgrade() -> None:
|
|
@@ -338,12 +338,7 @@ async def download_patient_summary_pdf(
|
|
| 338 |
allergies = db.query(Allergy).filter(Allergy.patient_id == patient_id).all()
|
| 339 |
diagnoses = db.query(Diagnosis).filter(Diagnosis.patient_id == patient_id).all()
|
| 340 |
contacts = db.query(EmergencyContact).filter(EmergencyContact.patient_id == patient_id).all()
|
| 341 |
-
care_plans = (
|
| 342 |
-
db.query(CarePlan)
|
| 343 |
-
.filter(CarePlan.patient_id == patient_id)
|
| 344 |
-
.order_by(desc(CarePlan.updated_at))
|
| 345 |
-
.all()
|
| 346 |
-
)
|
| 347 |
screenings = (
|
| 348 |
db.query(Screening)
|
| 349 |
.filter(Screening.patient_id == patient_id)
|
|
@@ -367,37 +362,50 @@ async def download_patient_summary_pdf(
|
|
| 367 |
export_dict = {
|
| 368 |
"medications": [
|
| 369 |
{
|
| 370 |
-
"name": m.name,
|
| 371 |
-
"
|
|
|
|
|
|
|
|
|
|
| 372 |
"is_active": m.is_active,
|
| 373 |
}
|
| 374 |
for m in medications
|
| 375 |
],
|
| 376 |
"allergies": [
|
| 377 |
{
|
| 378 |
-
"allergen": a.allergen,
|
| 379 |
-
"
|
|
|
|
|
|
|
|
|
|
| 380 |
}
|
| 381 |
for a in allergies
|
| 382 |
],
|
| 383 |
"diagnoses": [
|
| 384 |
{
|
| 385 |
-
"condition": d.condition,
|
| 386 |
-
"
|
|
|
|
|
|
|
|
|
|
| 387 |
}
|
| 388 |
for d in diagnoses
|
| 389 |
],
|
| 390 |
"emergency_contacts": [
|
| 391 |
{
|
| 392 |
-
"contact_name": c.contact_name,
|
| 393 |
-
"
|
|
|
|
|
|
|
| 394 |
}
|
| 395 |
for c in contacts
|
| 396 |
],
|
| 397 |
"care_plans": [
|
| 398 |
{
|
| 399 |
-
"title": cp.title,
|
| 400 |
-
"
|
|
|
|
|
|
|
| 401 |
}
|
| 402 |
for cp in care_plans
|
| 403 |
],
|
|
@@ -1250,12 +1258,7 @@ async def list_diagnoses(
|
|
| 1250 |
):
|
| 1251 |
"""List a patient's diagnoses (clinician view)."""
|
| 1252 |
_verify_patient_access(db, patient_id, current_user.id)
|
| 1253 |
-
rows = (
|
| 1254 |
-
db.query(Diagnosis)
|
| 1255 |
-
.filter(Diagnosis.patient_id == patient_id)
|
| 1256 |
-
.order_by(desc(Diagnosis.created_at))
|
| 1257 |
-
.all()
|
| 1258 |
-
)
|
| 1259 |
return [_diagnosis_to_response(dx) for dx in rows]
|
| 1260 |
|
| 1261 |
|
|
@@ -1376,12 +1379,7 @@ async def list_patient_medications(
|
|
| 1376 |
):
|
| 1377 |
"""List a patient's medications (clinician view)."""
|
| 1378 |
_verify_patient_access(db, patient_id, current_user.id)
|
| 1379 |
-
meds = (
|
| 1380 |
-
db.query(Medication)
|
| 1381 |
-
.filter(Medication.patient_id == patient_id)
|
| 1382 |
-
.order_by(desc(Medication.created_at))
|
| 1383 |
-
.all()
|
| 1384 |
-
)
|
| 1385 |
return [_medication_to_response(m) for m in meds]
|
| 1386 |
|
| 1387 |
|
|
@@ -1491,7 +1489,9 @@ async def update_patient_medication(
|
|
| 1491 |
|
| 1492 |
db.commit()
|
| 1493 |
db.refresh(med)
|
| 1494 |
-
log_audit(
|
|
|
|
|
|
|
| 1495 |
|
| 1496 |
return _medication_to_response(med)
|
| 1497 |
|
|
@@ -1513,7 +1513,13 @@ async def deactivate_patient_medication(
|
|
| 1513 |
|
| 1514 |
med.is_active = False
|
| 1515 |
db.commit()
|
| 1516 |
-
log_audit(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1517 |
|
| 1518 |
return {"status": "deactivated", "medication_id": medication_id}
|
| 1519 |
|
|
@@ -1640,7 +1646,12 @@ async def assign_patient_screening_schedule(
|
|
| 1640 |
db.add(schedule)
|
| 1641 |
|
| 1642 |
# Notify the patient
|
| 1643 |
-
freq_label = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1644 |
db.add(
|
| 1645 |
Notification(
|
| 1646 |
user_id=patient_id,
|
|
|
|
| 338 |
allergies = db.query(Allergy).filter(Allergy.patient_id == patient_id).all()
|
| 339 |
diagnoses = db.query(Diagnosis).filter(Diagnosis.patient_id == patient_id).all()
|
| 340 |
contacts = db.query(EmergencyContact).filter(EmergencyContact.patient_id == patient_id).all()
|
| 341 |
+
care_plans = db.query(CarePlan).filter(CarePlan.patient_id == patient_id).order_by(desc(CarePlan.updated_at)).all()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
screenings = (
|
| 343 |
db.query(Screening)
|
| 344 |
.filter(Screening.patient_id == patient_id)
|
|
|
|
| 362 |
export_dict = {
|
| 363 |
"medications": [
|
| 364 |
{
|
| 365 |
+
"name": m.name,
|
| 366 |
+
"dosage": m.dosage,
|
| 367 |
+
"frequency": m.frequency,
|
| 368 |
+
"start_date": m.start_date,
|
| 369 |
+
"prescribed_by": m.prescribed_by,
|
| 370 |
"is_active": m.is_active,
|
| 371 |
}
|
| 372 |
for m in medications
|
| 373 |
],
|
| 374 |
"allergies": [
|
| 375 |
{
|
| 376 |
+
"allergen": a.allergen,
|
| 377 |
+
"allergy_type": a.allergy_type,
|
| 378 |
+
"severity": a.severity,
|
| 379 |
+
"reaction": a.reaction,
|
| 380 |
+
"notes": a.notes,
|
| 381 |
}
|
| 382 |
for a in allergies
|
| 383 |
],
|
| 384 |
"diagnoses": [
|
| 385 |
{
|
| 386 |
+
"condition": d.condition,
|
| 387 |
+
"icd10_code": d.icd10_code,
|
| 388 |
+
"status": d.status,
|
| 389 |
+
"diagnosed_date": d.diagnosed_date,
|
| 390 |
+
"diagnosed_by": d.diagnosed_by,
|
| 391 |
}
|
| 392 |
for d in diagnoses
|
| 393 |
],
|
| 394 |
"emergency_contacts": [
|
| 395 |
{
|
| 396 |
+
"contact_name": c.contact_name,
|
| 397 |
+
"phone": c.phone,
|
| 398 |
+
"relation": c.relation,
|
| 399 |
+
"is_primary": c.is_primary,
|
| 400 |
}
|
| 401 |
for c in contacts
|
| 402 |
],
|
| 403 |
"care_plans": [
|
| 404 |
{
|
| 405 |
+
"title": cp.title,
|
| 406 |
+
"description": cp.description,
|
| 407 |
+
"status": cp.status,
|
| 408 |
+
"review_date": cp.review_date,
|
| 409 |
}
|
| 410 |
for cp in care_plans
|
| 411 |
],
|
|
|
|
| 1258 |
):
|
| 1259 |
"""List a patient's diagnoses (clinician view)."""
|
| 1260 |
_verify_patient_access(db, patient_id, current_user.id)
|
| 1261 |
+
rows = db.query(Diagnosis).filter(Diagnosis.patient_id == patient_id).order_by(desc(Diagnosis.created_at)).all()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1262 |
return [_diagnosis_to_response(dx) for dx in rows]
|
| 1263 |
|
| 1264 |
|
|
|
|
| 1379 |
):
|
| 1380 |
"""List a patient's medications (clinician view)."""
|
| 1381 |
_verify_patient_access(db, patient_id, current_user.id)
|
| 1382 |
+
meds = db.query(Medication).filter(Medication.patient_id == patient_id).order_by(desc(Medication.created_at)).all()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1383 |
return [_medication_to_response(m) for m in meds]
|
| 1384 |
|
| 1385 |
|
|
|
|
| 1489 |
|
| 1490 |
db.commit()
|
| 1491 |
db.refresh(med)
|
| 1492 |
+
log_audit(
|
| 1493 |
+
db, current_user.id, "medication_updated_by_clinician", resource_type="medication", resource_id=medication_id
|
| 1494 |
+
)
|
| 1495 |
|
| 1496 |
return _medication_to_response(med)
|
| 1497 |
|
|
|
|
| 1513 |
|
| 1514 |
med.is_active = False
|
| 1515 |
db.commit()
|
| 1516 |
+
log_audit(
|
| 1517 |
+
db,
|
| 1518 |
+
current_user.id,
|
| 1519 |
+
"medication_deactivated_by_clinician",
|
| 1520 |
+
resource_type="medication",
|
| 1521 |
+
resource_id=medication_id,
|
| 1522 |
+
)
|
| 1523 |
|
| 1524 |
return {"status": "deactivated", "medication_id": medication_id}
|
| 1525 |
|
|
|
|
| 1646 |
db.add(schedule)
|
| 1647 |
|
| 1648 |
# Notify the patient
|
| 1649 |
+
freq_label = {
|
| 1650 |
+
"weekly": "weekly",
|
| 1651 |
+
"biweekly": "every two weeks",
|
| 1652 |
+
"monthly": "monthly",
|
| 1653 |
+
"custom": f"every {body.custom_days} days",
|
| 1654 |
+
}.get(body.frequency, body.frequency)
|
| 1655 |
db.add(
|
| 1656 |
Notification(
|
| 1657 |
user_id=patient_id,
|
|
@@ -14,7 +14,6 @@ from sqlalchemy.orm import Session
|
|
| 14 |
|
| 15 |
from app.middleware.rate_limiter import limiter
|
| 16 |
from app.models.db import Screening, User, get_db
|
| 17 |
-
from app.services.reports import build_screening_pdf
|
| 18 |
from app.schemas.analysis import (
|
| 19 |
Evidence,
|
| 20 |
ExplanationReport,
|
|
@@ -25,6 +24,7 @@ from app.schemas.analysis import (
|
|
| 25 |
VerificationReport,
|
| 26 |
)
|
| 27 |
from app.services.auth import get_current_user
|
|
|
|
| 28 |
|
| 29 |
router = APIRouter()
|
| 30 |
logger = logging.getLogger(__name__)
|
|
@@ -192,8 +192,7 @@ async def download_screening_pdf(
|
|
| 192 |
"severity_label": screening.severity_level or "none",
|
| 193 |
"severity_score": symptom_data.get("total_sentences_analyzed"),
|
| 194 |
"symptoms": [
|
| 195 |
-
{"criterion": sym.get("dsm5_criterion", sym.get("criterion", "")),
|
| 196 |
-
"confidence": sym.get("confidence", 0)}
|
| 197 |
for sym in symptom_data.get("symptoms_detected", [])
|
| 198 |
],
|
| 199 |
"detected_sentences": [
|
|
|
|
| 14 |
|
| 15 |
from app.middleware.rate_limiter import limiter
|
| 16 |
from app.models.db import Screening, User, get_db
|
|
|
|
| 17 |
from app.schemas.analysis import (
|
| 18 |
Evidence,
|
| 19 |
ExplanationReport,
|
|
|
|
| 24 |
VerificationReport,
|
| 25 |
)
|
| 26 |
from app.services.auth import get_current_user
|
| 27 |
+
from app.services.reports import build_screening_pdf
|
| 28 |
|
| 29 |
router = APIRouter()
|
| 30 |
logger = logging.getLogger(__name__)
|
|
|
|
| 192 |
"severity_label": screening.severity_level or "none",
|
| 193 |
"severity_score": symptom_data.get("total_sentences_analyzed"),
|
| 194 |
"symptoms": [
|
| 195 |
+
{"criterion": sym.get("dsm5_criterion", sym.get("criterion", "")), "confidence": sym.get("confidence", 0)}
|
|
|
|
| 196 |
for sym in symptom_data.get("symptoms_detected", [])
|
| 197 |
],
|
| 198 |
"detected_sentences": [
|
|
@@ -6,7 +6,6 @@ symptom trends, data export, emergency contacts, medications,
|
|
| 6 |
allergies, diagnoses, screening schedules, and onboarding.
|
| 7 |
"""
|
| 8 |
|
| 9 |
-
import base64
|
| 10 |
import logging
|
| 11 |
from datetime import date, datetime, timedelta
|
| 12 |
from typing import Optional
|
|
@@ -352,10 +351,7 @@ async def upload_my_document_file(
|
|
| 352 |
content_type = (file.content_type or "").lower()
|
| 353 |
|
| 354 |
is_pdf = filename.endswith(".pdf") or content_type == "application/pdf"
|
| 355 |
-
is_text = (
|
| 356 |
-
filename.endswith(".txt")
|
| 357 |
-
or content_type.startswith("text/")
|
| 358 |
-
)
|
| 359 |
|
| 360 |
if is_pdf:
|
| 361 |
try:
|
|
@@ -1312,10 +1308,7 @@ async def export_my_data_pdf(
|
|
| 1312 |
from app.services.reports import build_patient_export_pdf
|
| 1313 |
|
| 1314 |
screenings = (
|
| 1315 |
-
db.query(Screening)
|
| 1316 |
-
.filter(Screening.patient_id == current_user.id)
|
| 1317 |
-
.order_by(desc(Screening.created_at))
|
| 1318 |
-
.all()
|
| 1319 |
)
|
| 1320 |
documents = db.query(PatientDocument).filter(PatientDocument.patient_id == current_user.id).all()
|
| 1321 |
contacts = db.query(EmergencyContact).filter(EmergencyContact.patient_id == current_user.id).all()
|
|
@@ -1350,44 +1343,53 @@ async def export_my_data_pdf(
|
|
| 1350 |
],
|
| 1351 |
"medications": [
|
| 1352 |
{
|
| 1353 |
-
"name": m.name,
|
| 1354 |
-
"
|
|
|
|
|
|
|
|
|
|
| 1355 |
"is_active": m.is_active,
|
| 1356 |
}
|
| 1357 |
for m in medications
|
| 1358 |
],
|
| 1359 |
"allergies": [
|
| 1360 |
{
|
| 1361 |
-
"allergen": a.allergen,
|
| 1362 |
-
"
|
|
|
|
|
|
|
| 1363 |
}
|
| 1364 |
for a in allergies
|
| 1365 |
],
|
| 1366 |
"diagnoses": [
|
| 1367 |
{
|
| 1368 |
-
"condition": d.condition,
|
| 1369 |
-
"
|
|
|
|
|
|
|
|
|
|
| 1370 |
}
|
| 1371 |
for d in diagnoses
|
| 1372 |
],
|
| 1373 |
"emergency_contacts": [
|
| 1374 |
{
|
| 1375 |
-
"contact_name": c.contact_name,
|
| 1376 |
-
"
|
|
|
|
|
|
|
| 1377 |
}
|
| 1378 |
for c in contacts
|
| 1379 |
],
|
| 1380 |
"care_plans": [
|
| 1381 |
{
|
| 1382 |
-
"title": cp.title,
|
| 1383 |
-
"
|
|
|
|
|
|
|
| 1384 |
}
|
| 1385 |
for cp in care_plans
|
| 1386 |
],
|
| 1387 |
-
"documents": [
|
| 1388 |
-
{"title": d.title, "doc_type": d.doc_type, "created_at": d.created_at}
|
| 1389 |
-
for d in documents
|
| 1390 |
-
],
|
| 1391 |
}
|
| 1392 |
|
| 1393 |
buf = build_patient_export_pdf(patient_dict, export_dict)
|
|
@@ -1498,6 +1500,7 @@ async def mark_notification_read(
|
|
| 1498 |
|
| 1499 |
# ββ Profile Picture Upload βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1500 |
|
|
|
|
| 1501 |
@router.post("/profile/picture")
|
| 1502 |
@limiter.limit("10/minute")
|
| 1503 |
async def upload_profile_picture(
|
|
|
|
| 6 |
allergies, diagnoses, screening schedules, and onboarding.
|
| 7 |
"""
|
| 8 |
|
|
|
|
| 9 |
import logging
|
| 10 |
from datetime import date, datetime, timedelta
|
| 11 |
from typing import Optional
|
|
|
|
| 351 |
content_type = (file.content_type or "").lower()
|
| 352 |
|
| 353 |
is_pdf = filename.endswith(".pdf") or content_type == "application/pdf"
|
| 354 |
+
is_text = filename.endswith(".txt") or content_type.startswith("text/")
|
|
|
|
|
|
|
|
|
|
| 355 |
|
| 356 |
if is_pdf:
|
| 357 |
try:
|
|
|
|
| 1308 |
from app.services.reports import build_patient_export_pdf
|
| 1309 |
|
| 1310 |
screenings = (
|
| 1311 |
+
db.query(Screening).filter(Screening.patient_id == current_user.id).order_by(desc(Screening.created_at)).all()
|
|
|
|
|
|
|
|
|
|
| 1312 |
)
|
| 1313 |
documents = db.query(PatientDocument).filter(PatientDocument.patient_id == current_user.id).all()
|
| 1314 |
contacts = db.query(EmergencyContact).filter(EmergencyContact.patient_id == current_user.id).all()
|
|
|
|
| 1343 |
],
|
| 1344 |
"medications": [
|
| 1345 |
{
|
| 1346 |
+
"name": m.name,
|
| 1347 |
+
"dosage": m.dosage,
|
| 1348 |
+
"frequency": m.frequency,
|
| 1349 |
+
"start_date": m.start_date,
|
| 1350 |
+
"prescribed_by": m.prescribed_by,
|
| 1351 |
"is_active": m.is_active,
|
| 1352 |
}
|
| 1353 |
for m in medications
|
| 1354 |
],
|
| 1355 |
"allergies": [
|
| 1356 |
{
|
| 1357 |
+
"allergen": a.allergen,
|
| 1358 |
+
"allergy_type": a.allergy_type,
|
| 1359 |
+
"severity": a.severity,
|
| 1360 |
+
"reaction": a.reaction,
|
| 1361 |
}
|
| 1362 |
for a in allergies
|
| 1363 |
],
|
| 1364 |
"diagnoses": [
|
| 1365 |
{
|
| 1366 |
+
"condition": d.condition,
|
| 1367 |
+
"icd10_code": d.icd10_code,
|
| 1368 |
+
"status": d.status,
|
| 1369 |
+
"diagnosed_date": d.diagnosed_date,
|
| 1370 |
+
"diagnosed_by": d.diagnosed_by,
|
| 1371 |
}
|
| 1372 |
for d in diagnoses
|
| 1373 |
],
|
| 1374 |
"emergency_contacts": [
|
| 1375 |
{
|
| 1376 |
+
"contact_name": c.contact_name,
|
| 1377 |
+
"phone": c.phone,
|
| 1378 |
+
"relation": c.relation,
|
| 1379 |
+
"is_primary": c.is_primary,
|
| 1380 |
}
|
| 1381 |
for c in contacts
|
| 1382 |
],
|
| 1383 |
"care_plans": [
|
| 1384 |
{
|
| 1385 |
+
"title": cp.title,
|
| 1386 |
+
"description": cp.description,
|
| 1387 |
+
"status": cp.status,
|
| 1388 |
+
"review_date": cp.review_date,
|
| 1389 |
}
|
| 1390 |
for cp in care_plans
|
| 1391 |
],
|
| 1392 |
+
"documents": [{"title": d.title, "doc_type": d.doc_type, "created_at": d.created_at} for d in documents],
|
|
|
|
|
|
|
|
|
|
| 1393 |
}
|
| 1394 |
|
| 1395 |
buf = build_patient_export_pdf(patient_dict, export_dict)
|
|
|
|
| 1500 |
|
| 1501 |
# ββ Profile Picture Upload βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1502 |
|
| 1503 |
+
|
| 1504 |
@router.post("/profile/picture")
|
| 1505 |
@limiter.limit("10/minute")
|
| 1506 |
async def upload_profile_picture(
|
|
@@ -20,7 +20,7 @@ from __future__ import annotations
|
|
| 20 |
import logging
|
| 21 |
|
| 22 |
import httpx
|
| 23 |
-
from fastapi import APIRouter, Depends,
|
| 24 |
|
| 25 |
from app.middleware.rate_limiter import limiter
|
| 26 |
from app.models.db import User
|
|
|
|
| 20 |
import logging
|
| 21 |
|
| 22 |
import httpx
|
| 23 |
+
from fastapi import APIRouter, Depends, Query, Request
|
| 24 |
|
| 25 |
from app.middleware.rate_limiter import limiter
|
| 26 |
from app.models.db import User
|
|
@@ -103,11 +103,7 @@ async def resend_webhook(
|
|
| 103 |
logger.info(f"Resend webhook {event_type} has no email_id, ignoring")
|
| 104 |
return {"status": "ignored", "reason": "missing_email_id"}
|
| 105 |
|
| 106 |
-
row = (
|
| 107 |
-
db.query(EmailDelivery)
|
| 108 |
-
.filter(EmailDelivery.resend_email_id == resend_email_id)
|
| 109 |
-
.first()
|
| 110 |
-
)
|
| 111 |
if not row:
|
| 112 |
# Not a crime β could be an email sent outside this app against the same Resend account.
|
| 113 |
logger.info(f"Resend webhook for unknown email {resend_email_id} ({event_type})")
|
|
@@ -115,18 +111,20 @@ async def resend_webhook(
|
|
| 115 |
|
| 116 |
# Append to event trail (JSON column)
|
| 117 |
events = list(row.events or [])
|
| 118 |
-
events.append(
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
| 130 |
row.events = events
|
| 131 |
row.last_event_at = datetime.utcnow()
|
| 132 |
|
|
|
|
| 103 |
logger.info(f"Resend webhook {event_type} has no email_id, ignoring")
|
| 104 |
return {"status": "ignored", "reason": "missing_email_id"}
|
| 105 |
|
| 106 |
+
row = db.query(EmailDelivery).filter(EmailDelivery.resend_email_id == resend_email_id).first()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
if not row:
|
| 108 |
# Not a crime β could be an email sent outside this app against the same Resend account.
|
| 109 |
logger.info(f"Resend webhook for unknown email {resend_email_id} ({event_type})")
|
|
|
|
| 111 |
|
| 112 |
# Append to event trail (JSON column)
|
| 113 |
events = list(row.events or [])
|
| 114 |
+
events.append(
|
| 115 |
+
{
|
| 116 |
+
"type": event_type,
|
| 117 |
+
"at": datetime.utcnow().isoformat(),
|
| 118 |
+
"raw": {
|
| 119 |
+
# Keep only the fields we actually want to retain β avoid storing PII
|
| 120 |
+
# we don't need. The `to` and subject are already on the row.
|
| 121 |
+
"bounce_type": (data.get("bounce") or {}).get("type"),
|
| 122 |
+
"bounce_subtype": (data.get("bounce") or {}).get("subType"),
|
| 123 |
+
"click_link": (data.get("click") or {}).get("link"),
|
| 124 |
+
"open_user_agent": (data.get("open") or {}).get("userAgent"),
|
| 125 |
+
},
|
| 126 |
+
}
|
| 127 |
+
)
|
| 128 |
row.events = events
|
| 129 |
row.last_event_at = datetime.utcnow()
|
| 130 |
|
|
@@ -61,7 +61,7 @@ def _before_send(event: dict, hint: dict) -> dict | None:
|
|
| 61 |
# Drop 4xx HTTPExceptions: those are expected validation failures
|
| 62 |
exc_info = hint.get("exc_info")
|
| 63 |
if exc_info:
|
| 64 |
-
|
| 65 |
try:
|
| 66 |
from fastapi import HTTPException
|
| 67 |
|
|
@@ -69,8 +69,10 @@ def _before_send(event: dict, hint: dict) -> dict | None:
|
|
| 69 |
status = getattr(exc_value, "status_code", 500)
|
| 70 |
if 400 <= status < 500:
|
| 71 |
return None
|
| 72 |
-
except Exception:
|
| 73 |
-
|
|
|
|
|
|
|
| 74 |
|
| 75 |
# Scrub the event payload
|
| 76 |
_scrub_mapping(event)
|
|
|
|
| 61 |
# Drop 4xx HTTPExceptions: those are expected validation failures
|
| 62 |
exc_info = hint.get("exc_info")
|
| 63 |
if exc_info:
|
| 64 |
+
_exc_type, exc_value, _tb = exc_info
|
| 65 |
try:
|
| 66 |
from fastapi import HTTPException
|
| 67 |
|
|
|
|
| 69 |
status = getattr(exc_value, "status_code", 500)
|
| 70 |
if 400 <= status < 500:
|
| 71 |
return None
|
| 72 |
+
except Exception as filter_err:
|
| 73 |
+
# Filtering isn't worth failing an error report over β log
|
| 74 |
+
# the filter-side failure so we notice if this path breaks.
|
| 75 |
+
logger.debug(f"HTTPException filter skipped: {filter_err}")
|
| 76 |
|
| 77 |
# Scrub the event payload
|
| 78 |
_scrub_mapping(event)
|
|
@@ -26,13 +26,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|
| 26 |
from starlette.requests import Request
|
| 27 |
from starlette.responses import Response
|
| 28 |
|
| 29 |
-
|
| 30 |
-
_BACKEND_CSP = (
|
| 31 |
-
"default-src 'none'; "
|
| 32 |
-
"frame-ancestors 'none'; "
|
| 33 |
-
"base-uri 'none'; "
|
| 34 |
-
"form-action 'none'"
|
| 35 |
-
)
|
| 36 |
|
| 37 |
|
| 38 |
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
|
|
| 26 |
from starlette.requests import Request
|
| 27 |
from starlette.responses import Response
|
| 28 |
|
| 29 |
+
_BACKEND_CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
@@ -502,11 +502,12 @@ def init_db():
|
|
| 502 |
import logging
|
| 503 |
from pathlib import Path
|
| 504 |
|
| 505 |
-
from alembic import command
|
| 506 |
from alembic.config import Config
|
| 507 |
from alembic.runtime.migration import MigrationContext
|
| 508 |
from sqlalchemy import text
|
| 509 |
|
|
|
|
|
|
|
| 510 |
log = logging.getLogger(__name__)
|
| 511 |
|
| 512 |
# pgvector is a Supabase extension; on SQLite this is a no-op.
|
|
|
|
| 502 |
import logging
|
| 503 |
from pathlib import Path
|
| 504 |
|
|
|
|
| 505 |
from alembic.config import Config
|
| 506 |
from alembic.runtime.migration import MigrationContext
|
| 507 |
from sqlalchemy import text
|
| 508 |
|
| 509 |
+
from alembic import command
|
| 510 |
+
|
| 511 |
log = logging.getLogger(__name__)
|
| 512 |
|
| 513 |
# pgvector is a Supabase extension; on SQLite this is a no-op.
|
|
@@ -154,9 +154,7 @@ class AvatarService:
|
|
| 154 |
raise RuntimeError("Avatar storage is not configured on this server.")
|
| 155 |
|
| 156 |
if content_type and content_type.lower() not in ALLOWED_MIME_TYPES:
|
| 157 |
-
raise AvatarError(
|
| 158 |
-
"Only JPG, PNG, WebP, and HEIC images are supported."
|
| 159 |
-
)
|
| 160 |
|
| 161 |
webp_bytes = _normalize_to_webp(raw_bytes)
|
| 162 |
|
|
@@ -179,9 +177,7 @@ class AvatarService:
|
|
| 179 |
)
|
| 180 |
except Exception as e:
|
| 181 |
logger.error(f"Supabase upload failed for user {user_id}: {e}")
|
| 182 |
-
raise RuntimeError(
|
| 183 |
-
"We couldn't save your picture. Please try again in a moment."
|
| 184 |
-
) from e
|
| 185 |
|
| 186 |
# Public URL from the bucket. Supabase returns {"publicUrl": "..."}.
|
| 187 |
url_resp: Any = self._client.storage.from_(self.bucket).get_public_url(key)
|
|
@@ -193,6 +189,7 @@ class AvatarService:
|
|
| 193 |
# Append a version query-string driven by upload time so the browser
|
| 194 |
# reliably pulls the new file.
|
| 195 |
import time
|
|
|
|
| 196 |
return f"{public_url}?v={int(time.time())}"
|
| 197 |
|
| 198 |
def delete(self, user_id: str) -> None:
|
|
|
|
| 154 |
raise RuntimeError("Avatar storage is not configured on this server.")
|
| 155 |
|
| 156 |
if content_type and content_type.lower() not in ALLOWED_MIME_TYPES:
|
| 157 |
+
raise AvatarError("Only JPG, PNG, WebP, and HEIC images are supported.")
|
|
|
|
|
|
|
| 158 |
|
| 159 |
webp_bytes = _normalize_to_webp(raw_bytes)
|
| 160 |
|
|
|
|
| 177 |
)
|
| 178 |
except Exception as e:
|
| 179 |
logger.error(f"Supabase upload failed for user {user_id}: {e}")
|
| 180 |
+
raise RuntimeError("We couldn't save your picture. Please try again in a moment.") from e
|
|
|
|
|
|
|
| 181 |
|
| 182 |
# Public URL from the bucket. Supabase returns {"publicUrl": "..."}.
|
| 183 |
url_resp: Any = self._client.storage.from_(self.bucket).get_public_url(key)
|
|
|
|
| 189 |
# Append a version query-string driven by upload time so the browser
|
| 190 |
# reliably pulls the new file.
|
| 191 |
import time
|
| 192 |
+
|
| 193 |
return f"{public_url}?v={int(time.time())}"
|
| 194 |
|
| 195 |
def delete(self, user_id: str) -> None:
|
|
@@ -316,7 +316,9 @@ class EmailService:
|
|
| 316 |
else:
|
| 317 |
subject = "Your check-in is ready"
|
| 318 |
headline = "A quiet reminder"
|
| 319 |
-
detail =
|
|
|
|
|
|
|
| 320 |
body = f"""
|
| 321 |
<h2>{headline}</h2>
|
| 322 |
<p>Hello {patient_name},</p>
|
|
|
|
| 316 |
else:
|
| 317 |
subject = "Your check-in is ready"
|
| 318 |
headline = "A quiet reminder"
|
| 319 |
+
detail = (
|
| 320 |
+
"A check-in is waiting for you today. Just a few minutes of reflection can be surprisingly helpful."
|
| 321 |
+
)
|
| 322 |
body = f"""
|
| 323 |
<h2>{headline}</h2>
|
| 324 |
<p>Hello {patient_name},</p>
|
|
@@ -77,9 +77,7 @@ def _ocr_sync(data: bytes) -> bytes:
|
|
| 77 |
import ocrmypdf
|
| 78 |
except ImportError as e:
|
| 79 |
logger.warning(f"ocrmypdf not installed, cannot OCR: {e}")
|
| 80 |
-
raise PDFExtractionError(
|
| 81 |
-
"This PDF appears to be a scan and OCR is not available in this environment."
|
| 82 |
-
) from e
|
| 83 |
|
| 84 |
in_buf = io.BytesIO(data)
|
| 85 |
out_buf = io.BytesIO()
|
|
@@ -116,7 +114,7 @@ async def _ocr_with_timeout(data: bytes) -> bytes:
|
|
| 116 |
asyncio.to_thread(_ocr_sync, data),
|
| 117 |
timeout=OCR_TIMEOUT_SECONDS,
|
| 118 |
)
|
| 119 |
-
except
|
| 120 |
raise PDFExtractionError(
|
| 121 |
f"OCR took longer than {OCR_TIMEOUT_SECONDS}s. For large scans, "
|
| 122 |
"please split into smaller PDFs or paste the content as text."
|
|
@@ -156,8 +154,6 @@ async def extract_text_from_pdf_bytes(data: bytes) -> str:
|
|
| 156 |
# Preserve any partial direct-extract text we may have had
|
| 157 |
if text:
|
| 158 |
return text
|
| 159 |
-
raise PDFExtractionError(
|
| 160 |
-
"No text was found in this PDF, even after OCR. Please type the content manually."
|
| 161 |
-
)
|
| 162 |
|
| 163 |
return text
|
|
|
|
| 77 |
import ocrmypdf
|
| 78 |
except ImportError as e:
|
| 79 |
logger.warning(f"ocrmypdf not installed, cannot OCR: {e}")
|
| 80 |
+
raise PDFExtractionError("This PDF appears to be a scan and OCR is not available in this environment.") from e
|
|
|
|
|
|
|
| 81 |
|
| 82 |
in_buf = io.BytesIO(data)
|
| 83 |
out_buf = io.BytesIO()
|
|
|
|
| 114 |
asyncio.to_thread(_ocr_sync, data),
|
| 115 |
timeout=OCR_TIMEOUT_SECONDS,
|
| 116 |
)
|
| 117 |
+
except TimeoutError:
|
| 118 |
raise PDFExtractionError(
|
| 119 |
f"OCR took longer than {OCR_TIMEOUT_SECONDS}s. For large scans, "
|
| 120 |
"please split into smaller PDFs or paste the content as text."
|
|
|
|
| 154 |
# Preserve any partial direct-extract text we may have had
|
| 155 |
if text:
|
| 156 |
return text
|
| 157 |
+
raise PDFExtractionError("No text was found in this PDF, even after OCR. Please type the content manually.")
|
|
|
|
|
|
|
| 158 |
|
| 159 |
return text
|
|
@@ -25,7 +25,6 @@ from reportlab.lib.units import cm
|
|
| 25 |
from reportlab.platypus import (
|
| 26 |
HRFlowable,
|
| 27 |
KeepTogether,
|
| 28 |
-
PageBreak,
|
| 29 |
Paragraph,
|
| 30 |
SimpleDocTemplate,
|
| 31 |
Spacer,
|
|
@@ -205,20 +204,24 @@ def _kv_table(rows: list[tuple[str, str]], s: dict[str, ParagraphStyle]) -> Tabl
|
|
| 205 |
data = []
|
| 206 |
for k, v in rows:
|
| 207 |
val = v if v not in (None, "") else "β"
|
| 208 |
-
data.append(
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
| 212 |
t = Table(data, colWidths=[4.5 * cm, None])
|
| 213 |
t.setStyle(
|
| 214 |
-
TableStyle(
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
| 222 |
)
|
| 223 |
return t
|
| 224 |
|
|
@@ -334,16 +337,18 @@ def build_screening_pdf(screening: dict, patient: dict) -> BytesIO:
|
|
| 334 |
data.append([sym.get("criterion", "β").replace("_", " ").title(), conf_str])
|
| 335 |
t = Table(data, colWidths=[9 * cm, 4 * cm])
|
| 336 |
t.setStyle(
|
| 337 |
-
TableStyle(
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
|
|
|
|
|
|
| 347 |
)
|
| 348 |
story.append(t)
|
| 349 |
|
|
@@ -444,13 +449,15 @@ def build_patient_export_pdf(patient: dict, export: dict) -> BytesIO:
|
|
| 444 |
else:
|
| 445 |
data = [["Name", "Dosage", "Frequency", "Prescribed by", "Started"]]
|
| 446 |
for m in meds:
|
| 447 |
-
data.append(
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
|
|
|
|
|
|
| 454 |
story.append(_grid(data, s))
|
| 455 |
|
| 456 |
# ββ Allergies ββββββββ
|
|
@@ -461,12 +468,14 @@ def build_patient_export_pdf(patient: dict, export: dict) -> BytesIO:
|
|
| 461 |
else:
|
| 462 |
data = [["Allergen", "Type", "Severity", "Reaction"]]
|
| 463 |
for a in allergies:
|
| 464 |
-
data.append(
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
|
|
|
|
|
|
| 470 |
story.append(_grid(data, s))
|
| 471 |
|
| 472 |
# ββ Diagnoses ββββββββ
|
|
@@ -477,13 +486,15 @@ def build_patient_export_pdf(patient: dict, export: dict) -> BytesIO:
|
|
| 477 |
else:
|
| 478 |
data = [["Condition", "ICD-10", "Status", "Diagnosed", "By"]]
|
| 479 |
for d in dxs:
|
| 480 |
-
data.append(
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
|
|
|
|
|
|
| 487 |
story.append(_grid(data, s))
|
| 488 |
|
| 489 |
# ββ Emergency contacts ββββββββ
|
|
@@ -494,12 +505,14 @@ def build_patient_export_pdf(patient: dict, export: dict) -> BytesIO:
|
|
| 494 |
else:
|
| 495 |
data = [["Name", "Phone", "Relation", "Primary"]]
|
| 496 |
for c in contacts:
|
| 497 |
-
data.append(
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
|
|
|
|
|
|
| 503 |
story.append(_grid(data, s))
|
| 504 |
|
| 505 |
# ββ Screenings ββββββββ
|
|
@@ -510,12 +523,14 @@ def build_patient_export_pdf(patient: dict, export: dict) -> BytesIO:
|
|
| 510 |
else:
|
| 511 |
data = [["Date", "Severity", "Score", "Flagged"]]
|
| 512 |
for sc in screenings:
|
| 513 |
-
data.append(
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
|
|
|
|
|
|
| 519 |
story.append(_grid(data, s))
|
| 520 |
|
| 521 |
# ββ Documents ββββββββ
|
|
@@ -564,7 +579,8 @@ def build_patient_summary_pdf(patient: dict, export: dict, clinician_name: str |
|
|
| 564 |
active_meds = [m for m in (export.get("medications") or []) if m.get("is_active") is not False]
|
| 565 |
active_dxs = [d for d in (export.get("diagnoses") or []) if d.get("status") == "active"]
|
| 566 |
life_threatening = [
|
| 567 |
-
a
|
|
|
|
| 568 |
if (a.get("severity") or "").lower() in {"life_threatening", "life-threatening", "severe"}
|
| 569 |
]
|
| 570 |
latest_screenings = (export.get("screenings") or [])[:5]
|
|
@@ -607,12 +623,14 @@ def build_patient_summary_pdf(patient: dict, export: dict, clinician_name: str |
|
|
| 607 |
data = [["Allergen", "Severity", "Reaction", "Notes"]]
|
| 608 |
sort_key = {"life_threatening": 0, "life-threatening": 0, "severe": 1, "moderate": 2, "mild": 3}
|
| 609 |
for a in sorted(allergies, key=lambda x: sort_key.get((x.get("severity") or "").lower(), 99)):
|
| 610 |
-
data.append(
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
|
|
|
|
|
|
| 616 |
story.append(_grid(data, s))
|
| 617 |
|
| 618 |
# ββ Medications ββββββββ
|
|
@@ -620,13 +638,15 @@ def build_patient_summary_pdf(patient: dict, export: dict, clinician_name: str |
|
|
| 620 |
story.append(Paragraph("Active medications", s["h2"]))
|
| 621 |
data = [["Name", "Dosage", "Frequency", "Started", "Prescribed by"]]
|
| 622 |
for m in active_meds:
|
| 623 |
-
data.append(
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
|
|
|
|
|
|
| 630 |
story.append(_grid(data, s))
|
| 631 |
|
| 632 |
# ββ Diagnoses ββββββββ
|
|
@@ -634,12 +654,14 @@ def build_patient_summary_pdf(patient: dict, export: dict, clinician_name: str |
|
|
| 634 |
story.append(Paragraph("Active diagnoses", s["h2"]))
|
| 635 |
data = [["Condition", "ICD-10", "Diagnosed", "By"]]
|
| 636 |
for d in active_dxs:
|
| 637 |
-
data.append(
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
|
|
|
|
|
|
| 643 |
story.append(_grid(data, s))
|
| 644 |
|
| 645 |
# ββ Screening trajectory ββββββββ
|
|
@@ -647,13 +669,15 @@ def build_patient_summary_pdf(patient: dict, export: dict, clinician_name: str |
|
|
| 647 |
story.append(Paragraph("Recent screenings", s["h2"]))
|
| 648 |
data = [["Date", "Severity", "Score", "Flagged", "Notes"]]
|
| 649 |
for sc in latest_screenings:
|
| 650 |
-
data.append(
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
|
|
|
|
|
|
| 657 |
story.append(_grid(data, s))
|
| 658 |
|
| 659 |
# ββ Care plan ββββββββ
|
|
@@ -662,15 +686,18 @@ def build_patient_summary_pdf(patient: dict, export: dict, clinician_name: str |
|
|
| 662 |
if active_cps:
|
| 663 |
story.append(Paragraph("Care plan", s["h2"]))
|
| 664 |
for cp in active_cps[:2]:
|
| 665 |
-
story.append(
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
|
|
|
|
|
|
|
|
|
| 674 |
|
| 675 |
# ββ Emergency contact ββββββββ
|
| 676 |
contacts = export.get("emergency_contacts") or []
|
|
@@ -714,15 +741,17 @@ def _grid(data: list[list[str]], s: dict[str, ParagraphStyle]) -> Table:
|
|
| 714 |
|
| 715 |
t = Table(wrapped, colWidths=col_widths, repeatRows=1)
|
| 716 |
t.setStyle(
|
| 717 |
-
TableStyle(
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
|
|
|
|
|
|
| 726 |
)
|
| 727 |
return t
|
| 728 |
|
|
|
|
| 25 |
from reportlab.platypus import (
|
| 26 |
HRFlowable,
|
| 27 |
KeepTogether,
|
|
|
|
| 28 |
Paragraph,
|
| 29 |
SimpleDocTemplate,
|
| 30 |
Spacer,
|
|
|
|
| 204 |
data = []
|
| 205 |
for k, v in rows:
|
| 206 |
val = v if v not in (None, "") else "β"
|
| 207 |
+
data.append(
|
| 208 |
+
[
|
| 209 |
+
Paragraph(f"<font color='#5A6170'>{k}</font>", s["meta"]),
|
| 210 |
+
Paragraph(str(val), s["body"]),
|
| 211 |
+
]
|
| 212 |
+
)
|
| 213 |
t = Table(data, colWidths=[4.5 * cm, None])
|
| 214 |
t.setStyle(
|
| 215 |
+
TableStyle(
|
| 216 |
+
[
|
| 217 |
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
| 218 |
+
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
| 219 |
+
("RIGHTPADDING", (0, 0), (-1, -1), 4),
|
| 220 |
+
("TOPPADDING", (0, 0), (-1, -1), 3),
|
| 221 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
| 222 |
+
("LINEBELOW", (0, 0), (-1, -2), 0.25, BORDER),
|
| 223 |
+
]
|
| 224 |
+
)
|
| 225 |
)
|
| 226 |
return t
|
| 227 |
|
|
|
|
| 337 |
data.append([sym.get("criterion", "β").replace("_", " ").title(), conf_str])
|
| 338 |
t = Table(data, colWidths=[9 * cm, 4 * cm])
|
| 339 |
t.setStyle(
|
| 340 |
+
TableStyle(
|
| 341 |
+
[
|
| 342 |
+
("BACKGROUND", (0, 0), (-1, 0), CREAM_DARK),
|
| 343 |
+
("TEXTCOLOR", (0, 0), (-1, 0), INK),
|
| 344 |
+
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
| 345 |
+
("FONTSIZE", (0, 0), (-1, -1), 9),
|
| 346 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
| 347 |
+
("TOPPADDING", (0, 0), (-1, -1), 6),
|
| 348 |
+
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
| 349 |
+
("LINEBELOW", (0, 0), (-1, -1), 0.25, BORDER),
|
| 350 |
+
]
|
| 351 |
+
)
|
| 352 |
)
|
| 353 |
story.append(t)
|
| 354 |
|
|
|
|
| 449 |
else:
|
| 450 |
data = [["Name", "Dosage", "Frequency", "Prescribed by", "Started"]]
|
| 451 |
for m in meds:
|
| 452 |
+
data.append(
|
| 453 |
+
[
|
| 454 |
+
_safe(m.get("name")),
|
| 455 |
+
_safe(m.get("dosage")),
|
| 456 |
+
_safe(m.get("frequency")),
|
| 457 |
+
_safe(m.get("prescribed_by")),
|
| 458 |
+
_format_date(m.get("start_date")),
|
| 459 |
+
]
|
| 460 |
+
)
|
| 461 |
story.append(_grid(data, s))
|
| 462 |
|
| 463 |
# ββ Allergies ββββββββ
|
|
|
|
| 468 |
else:
|
| 469 |
data = [["Allergen", "Type", "Severity", "Reaction"]]
|
| 470 |
for a in allergies:
|
| 471 |
+
data.append(
|
| 472 |
+
[
|
| 473 |
+
_safe(a.get("allergen")),
|
| 474 |
+
_safe(a.get("allergy_type")),
|
| 475 |
+
_safe(a.get("severity")),
|
| 476 |
+
_safe(a.get("reaction")),
|
| 477 |
+
]
|
| 478 |
+
)
|
| 479 |
story.append(_grid(data, s))
|
| 480 |
|
| 481 |
# ββ Diagnoses ββββββββ
|
|
|
|
| 486 |
else:
|
| 487 |
data = [["Condition", "ICD-10", "Status", "Diagnosed", "By"]]
|
| 488 |
for d in dxs:
|
| 489 |
+
data.append(
|
| 490 |
+
[
|
| 491 |
+
_safe(d.get("condition")),
|
| 492 |
+
_safe(d.get("icd10_code")),
|
| 493 |
+
_safe(d.get("status")),
|
| 494 |
+
_format_date(d.get("diagnosed_date")),
|
| 495 |
+
_safe(d.get("diagnosed_by")),
|
| 496 |
+
]
|
| 497 |
+
)
|
| 498 |
story.append(_grid(data, s))
|
| 499 |
|
| 500 |
# ββ Emergency contacts ββββββββ
|
|
|
|
| 505 |
else:
|
| 506 |
data = [["Name", "Phone", "Relation", "Primary"]]
|
| 507 |
for c in contacts:
|
| 508 |
+
data.append(
|
| 509 |
+
[
|
| 510 |
+
_safe(c.get("contact_name")),
|
| 511 |
+
_safe(c.get("phone")),
|
| 512 |
+
_safe(c.get("relation")),
|
| 513 |
+
"Yes" if c.get("is_primary") else "β",
|
| 514 |
+
]
|
| 515 |
+
)
|
| 516 |
story.append(_grid(data, s))
|
| 517 |
|
| 518 |
# ββ Screenings ββββββββ
|
|
|
|
| 523 |
else:
|
| 524 |
data = [["Date", "Severity", "Score", "Flagged"]]
|
| 525 |
for sc in screenings:
|
| 526 |
+
data.append(
|
| 527 |
+
[
|
| 528 |
+
_format_date(sc.get("created_at")),
|
| 529 |
+
_safe(sc.get("severity_label")).title(),
|
| 530 |
+
_safe(sc.get("severity_score")),
|
| 531 |
+
"Yes" if sc.get("flagged_for_review") else "β",
|
| 532 |
+
]
|
| 533 |
+
)
|
| 534 |
story.append(_grid(data, s))
|
| 535 |
|
| 536 |
# ββ Documents ββββββββ
|
|
|
|
| 579 |
active_meds = [m for m in (export.get("medications") or []) if m.get("is_active") is not False]
|
| 580 |
active_dxs = [d for d in (export.get("diagnoses") or []) if d.get("status") == "active"]
|
| 581 |
life_threatening = [
|
| 582 |
+
a
|
| 583 |
+
for a in (export.get("allergies") or [])
|
| 584 |
if (a.get("severity") or "").lower() in {"life_threatening", "life-threatening", "severe"}
|
| 585 |
]
|
| 586 |
latest_screenings = (export.get("screenings") or [])[:5]
|
|
|
|
| 623 |
data = [["Allergen", "Severity", "Reaction", "Notes"]]
|
| 624 |
sort_key = {"life_threatening": 0, "life-threatening": 0, "severe": 1, "moderate": 2, "mild": 3}
|
| 625 |
for a in sorted(allergies, key=lambda x: sort_key.get((x.get("severity") or "").lower(), 99)):
|
| 626 |
+
data.append(
|
| 627 |
+
[
|
| 628 |
+
_safe(a.get("allergen")),
|
| 629 |
+
_safe(a.get("severity")),
|
| 630 |
+
_safe(a.get("reaction")),
|
| 631 |
+
_safe(a.get("notes")),
|
| 632 |
+
]
|
| 633 |
+
)
|
| 634 |
story.append(_grid(data, s))
|
| 635 |
|
| 636 |
# ββ Medications ββββββββ
|
|
|
|
| 638 |
story.append(Paragraph("Active medications", s["h2"]))
|
| 639 |
data = [["Name", "Dosage", "Frequency", "Started", "Prescribed by"]]
|
| 640 |
for m in active_meds:
|
| 641 |
+
data.append(
|
| 642 |
+
[
|
| 643 |
+
_safe(m.get("name")),
|
| 644 |
+
_safe(m.get("dosage")),
|
| 645 |
+
_safe(m.get("frequency")),
|
| 646 |
+
_format_date(m.get("start_date")),
|
| 647 |
+
_safe(m.get("prescribed_by")),
|
| 648 |
+
]
|
| 649 |
+
)
|
| 650 |
story.append(_grid(data, s))
|
| 651 |
|
| 652 |
# ββ Diagnoses ββββββββ
|
|
|
|
| 654 |
story.append(Paragraph("Active diagnoses", s["h2"]))
|
| 655 |
data = [["Condition", "ICD-10", "Diagnosed", "By"]]
|
| 656 |
for d in active_dxs:
|
| 657 |
+
data.append(
|
| 658 |
+
[
|
| 659 |
+
_safe(d.get("condition")),
|
| 660 |
+
_safe(d.get("icd10_code")),
|
| 661 |
+
_format_date(d.get("diagnosed_date")),
|
| 662 |
+
_safe(d.get("diagnosed_by")),
|
| 663 |
+
]
|
| 664 |
+
)
|
| 665 |
story.append(_grid(data, s))
|
| 666 |
|
| 667 |
# ββ Screening trajectory ββββββββ
|
|
|
|
| 669 |
story.append(Paragraph("Recent screenings", s["h2"]))
|
| 670 |
data = [["Date", "Severity", "Score", "Flagged", "Notes"]]
|
| 671 |
for sc in latest_screenings:
|
| 672 |
+
data.append(
|
| 673 |
+
[
|
| 674 |
+
_format_date(sc.get("created_at")),
|
| 675 |
+
_safe(sc.get("severity_label")).title(),
|
| 676 |
+
_safe(sc.get("severity_score")),
|
| 677 |
+
"Yes" if sc.get("flagged_for_review") else "β",
|
| 678 |
+
_safe((sc.get("clinician_notes") or "")[:80]),
|
| 679 |
+
]
|
| 680 |
+
)
|
| 681 |
story.append(_grid(data, s))
|
| 682 |
|
| 683 |
# ββ Care plan ββββββββ
|
|
|
|
| 686 |
if active_cps:
|
| 687 |
story.append(Paragraph("Care plan", s["h2"]))
|
| 688 |
for cp in active_cps[:2]:
|
| 689 |
+
story.append(
|
| 690 |
+
KeepTogether(
|
| 691 |
+
[
|
| 692 |
+
Paragraph(f"<b>{_safe(cp.get('title'))}</b>", s["h3"]),
|
| 693 |
+
Paragraph(
|
| 694 |
+
f"Status: <b>{_safe(cp.get('status'))}</b> Β· Review: {_format_date(cp.get('review_date'))}",
|
| 695 |
+
s["meta"],
|
| 696 |
+
),
|
| 697 |
+
Paragraph(_safe(cp.get("description")), s["body"]),
|
| 698 |
+
]
|
| 699 |
+
)
|
| 700 |
+
)
|
| 701 |
|
| 702 |
# ββ Emergency contact ββββββββ
|
| 703 |
contacts = export.get("emergency_contacts") or []
|
|
|
|
| 741 |
|
| 742 |
t = Table(wrapped, colWidths=col_widths, repeatRows=1)
|
| 743 |
t.setStyle(
|
| 744 |
+
TableStyle(
|
| 745 |
+
[
|
| 746 |
+
("BACKGROUND", (0, 0), (-1, 0), CREAM_DARK),
|
| 747 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
| 748 |
+
("TOPPADDING", (0, 0), (-1, -1), 5),
|
| 749 |
+
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
| 750 |
+
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
| 751 |
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
| 752 |
+
("LINEBELOW", (0, 0), (-1, -1), 0.25, BORDER),
|
| 753 |
+
]
|
| 754 |
+
)
|
| 755 |
)
|
| 756 |
return t
|
| 757 |
|
|
@@ -31,6 +31,10 @@ ignore = [
|
|
| 31 |
[lint.per-file-ignores]
|
| 32 |
"ml/scripts/**" = ["S", "E", "F841", "B905", "F401"] # ML scripts are less strict
|
| 33 |
"ml/scripts/deprecated/**" = ["ALL"] # Deprecated scripts, not worth linting
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
[format]
|
| 36 |
quote-style = "double"
|
|
|
|
| 31 |
[lint.per-file-ignores]
|
| 32 |
"ml/scripts/**" = ["S", "E", "F841", "B905", "F401"] # ML scripts are less strict
|
| 33 |
"ml/scripts/deprecated/**" = ["ALL"] # Deprecated scripts, not worth linting
|
| 34 |
+
# Alembic autogenerates migration files with a fixed template; linting
|
| 35 |
+
# the template breaks autogenerate. `alembic revision --autogenerate`
|
| 36 |
+
# produces code we don't hand-edit unless fixing a diff.
|
| 37 |
+
"alembic/versions/*.py" = ["ALL"]
|
| 38 |
|
| 39 |
[format]
|
| 40 |
quote-style = "double"
|