halsabbah commited on
Commit
46a2f8b
Β·
1 Parent(s): d66606b

Fix CI lint + format + pinpoint pip-audit exception

Browse files

Ruff 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 CHANGED
@@ -83,10 +83,15 @@ jobs:
83
 
84
  - name: Audit backend requirements
85
  # --desc: show a one-line description of each CVE
86
- # --ignore-vuln: reserved hook for temporary pinned-but-flagged deps
87
- # We target requirements.txt directly so transitive deps are also
88
- # resolved and scanned, not just what happens to be installed in CI.
89
- run: pip-audit --requirement requirements.txt --desc --strict
 
 
 
 
 
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
alembic/versions/72deba99af9e_reconcile_deployed_schema_with_current_.py CHANGED
@@ -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 = '72deba99af9e'
16
- down_revision: Union[str, Sequence[str], None] = None
17
- branch_labels: Union[str, Sequence[str], None] = None
18
- depends_on: Union[str, Sequence[str], None] = None
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:
app/api/routes/dashboard.py CHANGED
@@ -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, "dosage": m.dosage, "frequency": m.frequency,
371
- "start_date": m.start_date, "prescribed_by": m.prescribed_by,
 
 
 
372
  "is_active": m.is_active,
373
  }
374
  for m in medications
375
  ],
376
  "allergies": [
377
  {
378
- "allergen": a.allergen, "allergy_type": a.allergy_type,
379
- "severity": a.severity, "reaction": a.reaction, "notes": a.notes,
 
 
 
380
  }
381
  for a in allergies
382
  ],
383
  "diagnoses": [
384
  {
385
- "condition": d.condition, "icd10_code": d.icd10_code, "status": d.status,
386
- "diagnosed_date": d.diagnosed_date, "diagnosed_by": d.diagnosed_by,
 
 
 
387
  }
388
  for d in diagnoses
389
  ],
390
  "emergency_contacts": [
391
  {
392
- "contact_name": c.contact_name, "phone": c.phone,
393
- "relation": c.relation, "is_primary": c.is_primary,
 
 
394
  }
395
  for c in contacts
396
  ],
397
  "care_plans": [
398
  {
399
- "title": cp.title, "description": cp.description,
400
- "status": cp.status, "review_date": cp.review_date,
 
 
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(db, current_user.id, "medication_updated_by_clinician", resource_type="medication", resource_id=medication_id)
 
 
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(db, current_user.id, "medication_deactivated_by_clinician", resource_type="medication", resource_id=medication_id)
 
 
 
 
 
 
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 = {"weekly": "weekly", "biweekly": "every two weeks", "monthly": "monthly", "custom": f"every {body.custom_days} days"}.get(body.frequency, body.frequency)
 
 
 
 
 
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,
app/api/routes/history.py CHANGED
@@ -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": [
app/api/routes/patient.py CHANGED
@@ -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, "dosage": m.dosage, "frequency": m.frequency,
1354
- "start_date": m.start_date, "prescribed_by": m.prescribed_by,
 
 
 
1355
  "is_active": m.is_active,
1356
  }
1357
  for m in medications
1358
  ],
1359
  "allergies": [
1360
  {
1361
- "allergen": a.allergen, "allergy_type": a.allergy_type,
1362
- "severity": a.severity, "reaction": a.reaction,
 
 
1363
  }
1364
  for a in allergies
1365
  ],
1366
  "diagnoses": [
1367
  {
1368
- "condition": d.condition, "icd10_code": d.icd10_code, "status": d.status,
1369
- "diagnosed_date": d.diagnosed_date, "diagnosed_by": d.diagnosed_by,
 
 
 
1370
  }
1371
  for d in diagnoses
1372
  ],
1373
  "emergency_contacts": [
1374
  {
1375
- "contact_name": c.contact_name, "phone": c.phone,
1376
- "relation": c.relation, "is_primary": c.is_primary,
 
 
1377
  }
1378
  for c in contacts
1379
  ],
1380
  "care_plans": [
1381
  {
1382
- "title": cp.title, "description": cp.description,
1383
- "status": cp.status, "review_date": cp.review_date,
 
 
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(
app/api/routes/terminology.py CHANGED
@@ -20,7 +20,7 @@ from __future__ import annotations
20
  import logging
21
 
22
  import httpx
23
- from fastapi import APIRouter, Depends, HTTPException, Query, Request
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
app/api/routes/webhooks.py CHANGED
@@ -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
- "type": event_type,
120
- "at": datetime.utcnow().isoformat(),
121
- "raw": {
122
- # Keep only the fields we actually want to retain β€” avoid storing PII
123
- # we don't need. The `to` and subject are already on the row.
124
- "bounce_type": (data.get("bounce") or {}).get("type"),
125
- "bounce_subtype": (data.get("bounce") or {}).get("subType"),
126
- "click_link": (data.get("click") or {}).get("link"),
127
- "open_user_agent": (data.get("open") or {}).get("userAgent"),
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
 
app/core/sentry.py CHANGED
@@ -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
- exc_type, exc_value, _ = exc_info
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
- pass
 
 
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)
app/middleware/security_headers.py CHANGED
@@ -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):
app/models/db.py CHANGED
@@ -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.
app/services/avatar.py CHANGED
@@ -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:
app/services/email.py CHANGED
@@ -316,7 +316,9 @@ class EmailService:
316
  else:
317
  subject = "Your check-in is ready"
318
  headline = "A quiet reminder"
319
- detail = "A check-in is waiting for you today. Just a few minutes of reflection can be surprisingly helpful."
 
 
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>
app/services/pdf_extractor.py CHANGED
@@ -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 asyncio.TimeoutError:
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
app/services/reports.py CHANGED
@@ -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
- Paragraph(f"<font color='#5A6170'>{k}</font>", s["meta"]),
210
- Paragraph(str(val), s["body"]),
211
- ])
 
 
212
  t = Table(data, colWidths=[4.5 * cm, None])
213
  t.setStyle(
214
- TableStyle([
215
- ("VALIGN", (0, 0), (-1, -1), "TOP"),
216
- ("LEFTPADDING", (0, 0), (-1, -1), 0),
217
- ("RIGHTPADDING", (0, 0), (-1, -1), 4),
218
- ("TOPPADDING", (0, 0), (-1, -1), 3),
219
- ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
220
- ("LINEBELOW", (0, 0), (-1, -2), 0.25, BORDER),
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
- ("BACKGROUND", (0, 0), (-1, 0), CREAM_DARK),
339
- ("TEXTCOLOR", (0, 0), (-1, 0), INK),
340
- ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
341
- ("FONTSIZE", (0, 0), (-1, -1), 9),
342
- ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
343
- ("TOPPADDING", (0, 0), (-1, -1), 6),
344
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
345
- ("LINEBELOW", (0, 0), (-1, -1), 0.25, BORDER),
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
- _safe(m.get("name")),
449
- _safe(m.get("dosage")),
450
- _safe(m.get("frequency")),
451
- _safe(m.get("prescribed_by")),
452
- _format_date(m.get("start_date")),
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
- _safe(a.get("allergen")),
466
- _safe(a.get("allergy_type")),
467
- _safe(a.get("severity")),
468
- _safe(a.get("reaction")),
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
- _safe(d.get("condition")),
482
- _safe(d.get("icd10_code")),
483
- _safe(d.get("status")),
484
- _format_date(d.get("diagnosed_date")),
485
- _safe(d.get("diagnosed_by")),
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
- _safe(c.get("contact_name")),
499
- _safe(c.get("phone")),
500
- _safe(c.get("relation")),
501
- "Yes" if c.get("is_primary") else "β€”",
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
- _format_date(sc.get("created_at")),
515
- _safe(sc.get("severity_label")).title(),
516
- _safe(sc.get("severity_score")),
517
- "Yes" if sc.get("flagged_for_review") else "β€”",
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 for a in (export.get("allergies") or [])
 
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
- _safe(a.get("allergen")),
612
- _safe(a.get("severity")),
613
- _safe(a.get("reaction")),
614
- _safe(a.get("notes")),
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
- _safe(m.get("name")),
625
- _safe(m.get("dosage")),
626
- _safe(m.get("frequency")),
627
- _format_date(m.get("start_date")),
628
- _safe(m.get("prescribed_by")),
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
- _safe(d.get("condition")),
639
- _safe(d.get("icd10_code")),
640
- _format_date(d.get("diagnosed_date")),
641
- _safe(d.get("diagnosed_by")),
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
- _format_date(sc.get("created_at")),
652
- _safe(sc.get("severity_label")).title(),
653
- _safe(sc.get("severity_score")),
654
- "Yes" if sc.get("flagged_for_review") else "β€”",
655
- _safe((sc.get("clinician_notes") or "")[:80]),
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(KeepTogether([
666
- Paragraph(f"<b>{_safe(cp.get('title'))}</b>", s["h3"]),
667
- Paragraph(
668
- f"Status: <b>{_safe(cp.get('status'))}</b> Β· "
669
- f"Review: {_format_date(cp.get('review_date'))}",
670
- s["meta"],
671
- ),
672
- Paragraph(_safe(cp.get("description")), s["body"]),
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
- ("BACKGROUND", (0, 0), (-1, 0), CREAM_DARK),
719
- ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
720
- ("TOPPADDING", (0, 0), (-1, -1), 5),
721
- ("LEFTPADDING", (0, 0), (-1, -1), 6),
722
- ("RIGHTPADDING", (0, 0), (-1, -1), 6),
723
- ("VALIGN", (0, 0), (-1, -1), "TOP"),
724
- ("LINEBELOW", (0, 0), (-1, -1), 0.25, BORDER),
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
 
ruff.toml CHANGED
@@ -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"