Ali2206 commited on
Commit
e307dc5
·
verified ·
1 Parent(s): 6ea5793

Update api/routes/patients.py

Browse files
Files changed (1) hide show
  1. api/routes/patients.py +439 -221
api/routes/patients.py CHANGED
@@ -1,15 +1,23 @@
1
- from fastapi import APIRouter, HTTPException, Depends, Response
2
- from ...db.mongo import patients_collection
3
- from ...core.security import get_current_user
4
- from ...utils.helpers import calculate_age, escape_latex_special_chars, hyphenate_long_strings, format_timestamp
 
 
5
  from datetime import datetime
6
  from bson import ObjectId
7
  from bson.errors import InvalidId
8
- import os
9
- import subprocess
10
- from tempfile import TemporaryDirectory
11
- from string import Template
 
 
 
 
12
  import logging
 
 
13
 
14
  # Configure logging
15
  logging.basicConfig(
@@ -20,231 +28,441 @@ logger = logging.getLogger(__name__)
20
 
21
  router = APIRouter()
22
 
23
- @router.get("/{patient_id}/pdf", response_class=Response)
24
- async def generate_patient_pdf(patient_id: str, current_user: dict = Depends(get_current_user)):
25
- # Suppress logging for this route
26
- logger.setLevel(logging.CRITICAL)
27
-
28
- try:
29
- if current_user.get('role') not in ['doctor', 'admin']:
30
- raise HTTPException(status_code=403, detail="Only clinicians can generate patient PDFs")
31
 
32
- # Determine if patient_id is ObjectId or fhir_id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  try:
34
- obj_id = ObjectId(patient_id)
35
- query = {"$or": [{"_id": obj_id}, {"fhir_id": patient_id}]}
36
- except InvalidId:
37
- query = {"fhir_id": patient_id}
38
-
39
- patient = await patients_collection.find_one(query)
40
- if not patient:
41
- raise HTTPException(status_code=404, detail="Patient not found")
42
-
43
- # Prepare table content
44
- notes = patient.get("notes", [])
45
- notes_content = ""
46
- if notes:
47
- notes_content = "\\toprule\n" + " \\\\\n".join(
48
- "{} & {} & {}".format(
49
- escape_latex_special_chars(hyphenate_long_strings(format_timestamp(n.get("date", "") or ""))),
50
- escape_latex_special_chars(hyphenate_long_strings(n.get("type", "") or "")),
51
- escape_latex_special_chars(hyphenate_long_strings(n.get("text", "") or ""))
52
- )
53
- for n in notes
54
- ) + "\n\\bottomrule"
55
- else:
56
- notes_content = "\\multicolumn{3}{l}{No notes available}"
57
-
58
- conditions = patient.get("conditions", [])
59
- conditions_content = ""
60
- if conditions:
61
- conditions_content = "\\toprule\n" + " \\\\\n".join(
62
- "{} & {} & {} & {} & {}".format(
63
- escape_latex_special_chars(hyphenate_long_strings(c.get("id", "") or "")),
64
- escape_latex_special_chars(hyphenate_long_strings(c.get("code", "") or "")),
65
- escape_latex_special_chars(hyphenate_long_strings(c.get("status", "") or "")),
66
- escape_latex_special_chars(hyphenate_long_strings(format_timestamp(c.get("onset_date", "") or ""))),
67
- escape_latex_special_chars(hyphenate_long_strings(c.get("verification_status", "") or ""))
68
- )
69
- for c in conditions
70
- ) + "\n\\bottomrule"
71
- else:
72
- conditions_content = "\\multicolumn{5}{l}{No conditions available}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
- medications = patient.get("medications", [])
75
- medications_content = ""
76
- if medications:
77
- medications_content = "\\toprule\n" + " \\\\\n".join(
78
- "{} & {} & {} & {} & {}".format(
79
- escape_latex_special_chars(hyphenate_long_strings(m.get("id", "") or "")),
80
- escape_latex_special_chars(hyphenate_long_strings(m.get("name", "") or "")),
81
- escape_latex_special_chars(hyphenate_long_strings(m.get("status", "") or "")),
82
- escape_latex_special_chars(hyphenate_long_strings(format_timestamp(m.get("prescribed_date", "") or ""))),
83
- escape_latex_special_chars(hyphenate_long_strings(m.get("dosage", "") or ""))
84
- )
85
- for m in medications
86
- ) + "\n\\bottomrule"
87
- else:
88
- medications_content = "\\multicolumn{5}{l}{No medications available}"
89
-
90
- encounters = patient.get("encounters", [])
91
- encounters_content = ""
92
- if encounters:
93
- encounters_content = "\\toprule\n" + " \\\\\n".join(
94
- "{} & {} & {} & {} & {}".format(
95
- escape_latex_special_chars(hyphenate_long_strings(e.get("id", "") or "")),
96
- escape_latex_special_chars(hyphenate_long_strings(e.get("type", "") or "")),
97
- escape_latex_special_chars(hyphenate_long_strings(e.get("status", "") or "")),
98
- escape_latex_special_chars(hyphenate_long_strings(format_timestamp(e.get("period", {}).get("start", "") or ""))),
99
- escape_latex_special_chars(hyphenate_long_strings(e.get("service_provider", "") or ""))
100
- )
101
- for e in encounters
102
- ) + "\n\\bottomrule"
103
- else:
104
- encounters_content = "\\multicolumn{5}{l}{No encounters available}"
105
-
106
- # Use Template for safe insertion
107
- latex_template = Template(r"""
108
- \documentclass[a4paper,12pt]{article}
109
- \usepackage[utf8]{inputenc}
110
- \usepackage[T1]{fontenc}
111
- \usepackage{geometry}
112
- \geometry{margin=1in}
113
- \usepackage{booktabs,longtable,fancyhdr}
114
- \usepackage{array}
115
- \usepackage{microtype}
116
- \microtypesetup{expansion=false} % Disable font expansion to avoid errors
117
- \setlength{\headheight}{14.5pt} % Fix fancyhdr warning
118
- \pagestyle{fancy}
119
- \fancyhf{}
120
- \fancyhead[L]{Patient Report}
121
- \fancyhead[R]{Generated: \today}
122
- \fancyfoot[C]{\thepage}
123
-
124
- \begin{document}
125
-
126
- \begin{center}
127
- \Large\textbf{Patient Medical Report} \\
128
- \vspace{0.2cm}
129
- \textit{Generated on $generated_on}
130
- \end{center}
131
-
132
- \section*{Demographics}
133
- \begin{itemize}
134
- \item \textbf{FHIR ID:} $fhir_id
135
- \item \textbf{Full Name:} $full_name
136
- \item \textbf{Gender:} $gender
137
- \item \textbf{Date of Birth:} $dob
138
- \item \textbf{Age:} $age
139
- \item \textbf{Address:} $address
140
- \item \textbf{Marital Status:} $marital_status
141
- \item \textbf{Language:} $language
142
- \end{itemize}
143
-
144
- \section*{Clinical Notes}
145
- \begin{longtable}{>{\raggedright\arraybackslash}p{3.5cm}>{\raggedright\arraybackslash}p{3cm}>{\raggedright\arraybackslash}p{6.5cm}}
146
- \textbf{Date} & \textbf{Type} & \textbf{Text} \\
147
- \endhead
148
- $notes
149
- \end{longtable}
150
-
151
- \section*{Conditions}
152
- \begin{longtable}{>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{3cm}>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{3.5cm}>{\raggedright\arraybackslash}p{3cm}}
153
- \textbf{ID} & \textbf{Code} & \textbf{Status} & \textbf{Onset} & \textbf{Verification} \\
154
- \endhead
155
- $conditions
156
- \end{longtable}
157
-
158
- \section*{Medications}
159
- \begin{longtable}{>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{4cm}>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{3.5cm}>{\raggedright\arraybackslash}p{3cm}}
160
- \textbf{ID} & \textbf{Name} & \textbf{Status} & \textbf{Date} & \textbf{Dosage} \\
161
- \endhead
162
- $medications
163
- \end{longtable}
164
-
165
- \section*{Encounters}
166
- \begin{longtable}{>{\raggedright\arraybackslash}p{2.5cm}>{\raggedright\arraybackslash}p{4.5cm}>{\raggedright\arraybackslash}p{2.5cm}>{\raggedright\arraybackslash}p{4.5cm}>{\raggedright\arraybackslash}p{3.5cm}}
167
- \textbf{ID} & \textbf{Type} & \textbf{Status} & \textbf{Start} & \textbf{Provider} \\
168
- \endhead
169
- $encounters
170
- \end{longtable}
171
-
172
- \end{document}
173
- """)
174
-
175
- # Set the generated_on date to 06:07 PM CET, May 16, 2025 (based on system note)
176
- generated_on = datetime.strptime("2025-05-16 18:07:00+02:00", "%Y-%m-%d %H:%M:%S%z").strftime("%A, %B %d, %Y at %I:%M %p %Z")
177
-
178
- latex_filled = latex_template.substitute(
179
- generated_on=generated_on,
180
- fhir_id=escape_latex_special_chars(hyphenate_long_strings(patient.get("fhir_id", "") or "")),
181
- full_name=escape_latex_special_chars(patient.get("full_name", "") or ""),
182
- gender=escape_latex_special_chars(patient.get("gender", "") or ""),
183
- dob=escape_latex_special_chars(patient.get("date_of_birth", "") or ""),
184
- age=escape_latex_special_chars(str(calculate_age(patient.get("date_of_birth", "")) or "N/A")),
185
- address=escape_latex_special_chars(", ".join(filter(None, [
186
- patient.get("address", ""),
187
- patient.get("city", ""),
188
- patient.get("state", ""),
189
- patient.get("postal_code", ""),
190
- patient.get("country", "")
191
- ]))),
192
- marital_status=escape_latex_special_chars(patient.get("marital_status", "") or ""),
193
- language=escape_latex_special_chars(patient.get("language", "") or ""),
194
- notes=notes_content,
195
- conditions=conditions_content,
196
- medications=medications_content,
197
- encounters=encounters_content
198
  )
199
-
200
- # Compile LaTeX in a temporary directory
201
- with TemporaryDirectory() as tmpdir:
202
- tex_path = os.path.join(tmpdir, "report.tex")
203
- pdf_path = os.path.join(tmpdir, "report.pdf")
204
-
205
- with open(tex_path, "w", encoding="utf-8") as f:
206
- f.write(latex_filled)
207
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  try:
209
- subprocess.run(
210
- ["latexmk", "-pdf", "-interaction=nonstopmode", tex_path],
211
- cwd=tmpdir,
212
- check=True,
213
- capture_output=True,
214
- text=True
215
- )
216
- except subprocess.CalledProcessError as e:
217
- raise HTTPException(
218
- status_code=500,
219
- detail=f"LaTeX compilation failed: stdout={e.stdout}, stderr={e.stderr}"
220
- )
221
-
222
- if not os.path.exists(pdf_path):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  raise HTTPException(
224
- status_code=500,
225
- detail="PDF file was not generated"
226
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
 
228
- with open(pdf_path, "rb") as f:
229
- pdf_bytes = f.read()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
 
231
- response = Response(
232
- content=pdf_bytes,
233
- media_type="application/pdf",
234
- headers={"Content-Disposition": f"attachment; filename=patient_{patient.get('fhir_id', 'unknown')}_report.pdf"}
 
 
 
 
 
 
 
 
 
 
 
 
235
  )
236
- return response
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
- except HTTPException as http_error:
239
- raise http_error
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  except Exception as e:
 
241
  raise HTTPException(
242
- status_code=500,
243
- detail=f"Unexpected error generating PDF: {str(e)}"
244
  )
245
- finally:
246
- # Restore the logger level for other routes
247
- logger.setLevel(logging.INFO)
248
 
249
- # Export the router as 'pdf' for api.__init__.py
250
- pdf = router
 
1
+ from fastapi import APIRouter, HTTPException, Depends, Query, status
2
+ from ..db.mongo import patients_collection
3
+ from ..core.security import get_current_user
4
+ from ..utils.db import create_indexes
5
+ from ..utils.helpers import calculate_age, standardize_language
6
+ from ..models.entities import Note
7
  from datetime import datetime
8
  from bson import ObjectId
9
  from bson.errors import InvalidId
10
+ from typing import Optional, List, Dict
11
+ from pymongo import UpdateOne
12
+ from pymongo.errors import BulkWriteError
13
+ import json
14
+ from pathlib import Path
15
+ import glob
16
+ import uuid
17
+ import re
18
  import logging
19
+ import time
20
+ import os
21
 
22
  # Configure logging
23
  logging.basicConfig(
 
28
 
29
  router = APIRouter()
30
 
31
+ # Configuration
32
+ BASE_DIR = Path(__file__).resolve().parent.parent.parent
33
+ SYNTHEA_DATA_DIR = BASE_DIR / "output" / "fhir"
34
+ os.makedirs(SYNTHEA_DATA_DIR, exist_ok=True)
 
 
 
 
35
 
36
+ async def process_synthea_patient(bundle: dict, file_path: str) -> Optional[dict]:
37
+ logger.debug(f"Processing patient from file: {file_path}")
38
+ patient_data = {}
39
+ notes = []
40
+ conditions = []
41
+ medications = []
42
+ encounters = []
43
+
44
+ # Validate bundle structure
45
+ if not isinstance(bundle, dict) or 'entry' not in bundle:
46
+ logger.error(f"Invalid FHIR bundle structure in {file_path}")
47
+ return None
48
+
49
+ for entry in bundle.get('entry', []):
50
+ resource = entry.get('resource', {})
51
+ resource_type = resource.get('resourceType')
52
+
53
+ if not resource_type:
54
+ logger.warning(f"Skipping entry with missing resourceType in {file_path}")
55
+ continue
56
+
57
  try:
58
+ if resource_type == 'Patient':
59
+ name = resource.get('name', [{}])[0]
60
+ address = resource.get('address', [{}])[0]
61
+
62
+ patient_data = {
63
+ 'fhir_id': resource.get('id'),
64
+ 'full_name': f"{' '.join(name.get('given', ['']))} {name.get('family', '')}".strip(),
65
+ 'gender': resource.get('gender', 'unknown'),
66
+ 'date_of_birth': resource.get('birthDate', ''),
67
+ 'address': ' '.join(address.get('line', [''])),
68
+ 'city': address.get('city', ''),
69
+ 'state': address.get('state', ''),
70
+ 'postal_code': address.get('postalCode', ''),
71
+ 'country': address.get('country', ''),
72
+ 'marital_status': resource.get('maritalStatus', {}).get('text', ''),
73
+ 'language': standardize_language(resource.get('communication', [{}])[0].get('language', {}).get('text', '')),
74
+ 'source': 'synthea',
75
+ 'last_updated': datetime.utcnow().isoformat()
76
+ }
77
+
78
+ elif resource_type == 'Encounter':
79
+ encounter = {
80
+ 'id': resource.get('id'),
81
+ 'type': resource.get('type', [{}])[0].get('text', ''),
82
+ 'status': resource.get('status'),
83
+ 'period': resource.get('period', {}),
84
+ 'service_provider': resource.get('serviceProvider', {}).get('display', '')
85
+ }
86
+ encounters.append(encounter)
87
+
88
+ for note in resource.get('note', []):
89
+ if note.get('text'):
90
+ notes.append({
91
+ 'date': resource.get('period', {}).get('start', datetime.utcnow().isoformat()),
92
+ 'type': resource.get('type', [{}])[0].get('text', 'Encounter Note'),
93
+ 'text': note.get('text'),
94
+ 'context': f"Encounter: {encounter.get('type')}",
95
+ 'author': 'System Generated'
96
+ })
97
+
98
+ elif resource_type == 'Condition':
99
+ conditions.append({
100
+ 'id': resource.get('id'),
101
+ 'code': resource.get('code', {}).get('text', ''),
102
+ 'status': resource.get('clinicalStatus', {}).get('text', ''),
103
+ 'onset_date': resource.get('onsetDateTime'),
104
+ 'recorded_date': resource.get('recordedDate'),
105
+ 'verification_status': resource.get('verificationStatus', {}).get('text', '')
106
+ })
107
+
108
+ elif resource_type == 'MedicationRequest':
109
+ medications.append({
110
+ 'id': resource.get('id'),
111
+ 'name': resource.get('medicationCodeableConcept', {}).get('text', ''),
112
+ 'status': resource.get('status'),
113
+ 'prescribed_date': resource.get('authoredOn'),
114
+ 'requester': resource.get('requester', {}).get('display', ''),
115
+ 'dosage': resource.get('dosageInstruction', [{}])[0].get('text', '')
116
+ })
117
+
118
+ except Exception as e:
119
+ logger.error(f"Error processing {resource_type} in {file_path}: {str(e)}")
120
+ continue
121
+
122
+ if patient_data:
123
+ patient_data.update({
124
+ 'notes': notes,
125
+ 'conditions': conditions,
126
+ 'medications': medications,
127
+ 'encounters': encounters,
128
+ 'import_date': datetime.utcnow().isoformat()
129
+ })
130
+ logger.info(f"Successfully processed patient {patient_data.get('fhir_id')} from {file_path}")
131
+ return patient_data
132
+ logger.warning(f"No valid patient data found in {file_path}")
133
+ return None
134
 
135
+ @router.post("/import", status_code=status.HTTP_201_CREATED)
136
+ async def import_patients(
137
+ limit: int = Query(100, ge=1, le=1000),
138
+ current_user: dict = Depends(get_current_user)
139
+ ):
140
+ request_id = str(uuid.uuid4())
141
+ logger.info(f"Starting import request {request_id} by user {current_user.get('email')}")
142
+ start_time = time.time()
143
+
144
+ if current_user.get('role') not in ['admin', 'doctor']:
145
+ logger.warning(f"Unauthorized import attempt by {current_user.get('email')}")
146
+ raise HTTPException(
147
+ status_code=status.HTTP_403_FORBIDDEN,
148
+ detail="Only administrators and doctors can import data"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  )
150
+
151
+ try:
152
+ await create_indexes()
153
+
154
+ if not SYNTHEA_DATA_DIR.exists():
155
+ logger.error(f"Synthea data directory not found: {SYNTHEA_DATA_DIR}")
156
+ raise HTTPException(
157
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
158
+ detail="Data directory not found"
159
+ )
160
+
161
+ # Filter out non-patient files
162
+ files = [
163
+ f for f in glob.glob(str(SYNTHEA_DATA_DIR / "*.json"))
164
+ if not re.search(r'(hospitalInformation|practitionerInformation)\d+\.json$', f)
165
+ ]
166
+ if not files:
167
+ logger.warning("No valid patient JSON files found in synthea data directory")
168
+ return {
169
+ "status": "success",
170
+ "message": "No patient data files found",
171
+ "imported": 0,
172
+ "request_id": request_id
173
+ }
174
+
175
+ operations = []
176
+ imported = 0
177
+ errors = []
178
+
179
+ for file_path in files[:limit]:
180
  try:
181
+ logger.debug(f"Processing file: {file_path}")
182
+
183
+ # Check file accessibility
184
+ if not os.path.exists(file_path):
185
+ logger.error(f"File not found: {file_path}")
186
+ errors.append(f"File not found: {file_path}")
187
+ continue
188
+
189
+ # Check file size
190
+ file_size = os.path.getsize(file_path)
191
+ if file_size == 0:
192
+ logger.warning(f"Empty file: {file_path}")
193
+ errors.append(f"Empty file: {file_path}")
194
+ continue
195
+
196
+ with open(file_path, 'r', encoding='utf-8') as f:
197
+ try:
198
+ bundle = json.load(f)
199
+ except json.JSONDecodeError as je:
200
+ logger.error(f"Invalid JSON in {file_path}: {str(je)}")
201
+ errors.append(f"Invalid JSON in {file_path}: {str(je)}")
202
+ continue
203
+
204
+ patient = await process_synthea_patient(bundle, file_path)
205
+ if patient:
206
+ if not patient.get('fhir_id'):
207
+ logger.warning(f"Missing FHIR ID in patient data from {file_path}")
208
+ errors.append(f"Missing FHIR ID in {file_path}")
209
+ continue
210
+
211
+ operations.append(UpdateOne(
212
+ {"fhir_id": patient['fhir_id']},
213
+ {"$setOnInsert": patient},
214
+ upsert=True
215
+ ))
216
+ imported += 1
217
+ else:
218
+ logger.warning(f"No valid patient data in {file_path}")
219
+ errors.append(f"No valid patient data in {file_path}")
220
+
221
+ except Exception as e:
222
+ logger.error(f"Error processing {file_path}: {str(e)}")
223
+ errors.append(f"Error in {file_path}: {str(e)}")
224
+ continue
225
+
226
+ response = {
227
+ "status": "success",
228
+ "imported": imported,
229
+ "errors": errors,
230
+ "request_id": request_id,
231
+ "duration_seconds": time.time() - start_time
232
+ }
233
+
234
+ if operations:
235
+ try:
236
+ result = await patients_collection.bulk_write(operations, ordered=False)
237
+ response.update({
238
+ "upserted": result.upserted_count,
239
+ "existing": len(operations) - result.upserted_count
240
+ })
241
+ logger.info(f"Import request {request_id} completed: {imported} patients processed, "
242
+ f"{result.upserted_count} upserted, {len(errors)} errors")
243
+ except BulkWriteError as bwe:
244
+ logger.error(f"Partial bulk write failure for request {request_id}: {str(bwe.details)}")
245
+ response.update({
246
+ "upserted": bwe.details.get('nUpserted', 0),
247
+ "existing": len(operations) - bwe.details.get('nUpserted', 0),
248
+ "write_errors": [
249
+ f"Index {err['index']}: {err['errmsg']}" for err in bwe.details.get('writeErrors', [])
250
+ ]
251
+ })
252
+ logger.info(f"Import request {request_id} partially completed: {imported} patients processed, "
253
+ f"{response['upserted']} upserted, {len(errors)} errors")
254
+ except Exception as e:
255
+ logger.error(f"Bulk write failed for request {request_id}: {str(e)}")
256
  raise HTTPException(
257
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
258
+ detail=f"Database operation failed: {str(e)}"
259
  )
260
+ else:
261
+ logger.info(f"Import request {request_id} completed: No new patients to import, {len(errors)} errors")
262
+ response["message"] = "No new patients found to import"
263
+
264
+ return response
265
+
266
+ except HTTPException:
267
+ raise
268
+ except Exception as e:
269
+ logger.error(f"Import request {request_id} failed: {str(e)}", exc_info=True)
270
+ raise HTTPException(
271
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
272
+ detail=f"Import failed: {str(e)}"
273
+ )
274
 
275
+ @router.get("/patients", response_model=List[dict])
276
+ async def list_patients(
277
+ search: Optional[str] = Query(None),
278
+ min_notes: int = Query(0, ge=0),
279
+ min_conditions: int = Query(0, ge=0),
280
+ limit: int = Query(100, ge=1, le=500),
281
+ skip: int = Query(0, ge=0)
282
+ ):
283
+ logger.info(f"Listing patients with search: {search}, limit: {limit}, skip: {skip}")
284
+ query = {"source": "synthea"}
285
+
286
+ if search:
287
+ query["$or"] = [
288
+ {"full_name": {"$regex": search, "$options": "i"}},
289
+ {"fhir_id": search}
290
+ ]
291
+
292
+ if min_notes > 0:
293
+ query[f"notes.{min_notes-1}"] = {"$exists": True}
294
+
295
+ if min_conditions > 0:
296
+ query[f"conditions.{min_conditions-1}"] = {"$exists": True}
297
+
298
+ # Removed $slice to return full arrays for the frontend
299
+ projection = {
300
+ "fhir_id": 1,
301
+ "full_name": 1,
302
+ "gender": 1,
303
+ "date_of_birth": 1,
304
+ "city": 1,
305
+ "state": 1,
306
+ "conditions": 1,
307
+ "medications": 1,
308
+ "encounters": 1,
309
+ "notes": 1
310
+ }
311
+
312
+ try:
313
+ cursor = patients_collection.find(query, projection).skip(skip).limit(limit)
314
+ patients = []
315
+
316
+ async for patient in cursor:
317
+ patients.append({
318
+ "id": str(patient["_id"]),
319
+ "fhir_id": patient.get("fhir_id"),
320
+ "full_name": patient.get("full_name"),
321
+ "gender": patient.get("gender"),
322
+ "date_of_birth": patient.get("date_of_birth"),
323
+ "city": patient.get("city"),
324
+ "state": patient.get("state"),
325
+ "conditions": patient.get("conditions", []),
326
+ "medications": patient.get("medications", []),
327
+ "encounters": patient.get("encounters", []),
328
+ "notes": patient.get("notes", []),
329
+ "age": calculate_age(patient.get("date_of_birth")),
330
+ "stats": {
331
+ "notes": len(patient.get("notes", [])),
332
+ "conditions": len(patient.get("conditions", [])),
333
+ "medications": len(patient.get("medications", []))
334
+ }
335
+ })
336
+
337
+ logger.info(f"Retrieved {len(patients)} patients")
338
+ return patients
339
+
340
+ except Exception as e:
341
+ logger.error(f"Failed to list patients: {str(e)}")
342
+ raise HTTPException(
343
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
344
+ detail=f"Failed to retrieve patients: {str(e)}"
345
+ )
346
 
347
+ @router.get("/patients/{patient_id}", response_model=dict)
348
+ async def get_patient(patient_id: str):
349
+ logger.info(f"Retrieving patient: {patient_id}")
350
+ try:
351
+ patient = await patients_collection.find_one({
352
+ "$or": [
353
+ {"_id": ObjectId(patient_id)},
354
+ {"fhir_id": patient_id}
355
+ ]
356
+ })
357
+
358
+ if not patient:
359
+ logger.warning(f"Patient not found: {patient_id}")
360
+ raise HTTPException(
361
+ status_code=status.HTTP_404_NOT_FOUND,
362
+ detail="Patient not found"
363
  )
364
+
365
+ response = {
366
+ "demographics": {
367
+ "id": str(patient["_id"]),
368
+ "fhir_id": patient.get("fhir_id"),
369
+ "full_name": patient.get("full_name"),
370
+ "gender": patient.get("gender"),
371
+ "date_of_birth": patient.get("date_of_birth"),
372
+ "age": calculate_age(patient.get("date_of_birth")),
373
+ "address": {
374
+ "line": patient.get("address"),
375
+ "city": patient.get("city"),
376
+ "state": patient.get("state"),
377
+ "postal_code": patient.get("postal_code"),
378
+ "country": patient.get("country")
379
+ },
380
+ "marital_status": patient.get("marital_status"),
381
+ "language": patient.get("language")
382
+ },
383
+ "clinical_data": {
384
+ "notes": patient.get("notes", []),
385
+ "conditions": patient.get("conditions", []),
386
+ "medications": patient.get("medications", []),
387
+ "encounters": patient.get("encounters", [])
388
+ },
389
+ "metadata": {
390
+ "source": patient.get("source"),
391
+ "import_date": patient.get("import_date"),
392
+ "last_updated": patient.get("last_updated")
393
+ }
394
+ }
395
+
396
+ logger.info(f"Successfully retrieved patient: {patient_id}")
397
+ return response
398
+
399
+ except ValueError as ve:
400
+ logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
401
+ raise HTTPException(
402
+ status_code=status.HTTP_400_BAD_REQUEST,
403
+ detail="Invalid patient ID format"
404
+ )
405
+ except Exception as e:
406
+ logger.error(f"Failed to retrieve patient {patient_id}: {str(e)}")
407
+ raise HTTPException(
408
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
409
+ detail=f"Failed to retrieve patient: {str(e)}"
410
+ )
411
 
412
+ @router.post("/patients/{patient_id}/notes", status_code=status.HTTP_201_CREATED)
413
+ async def add_note(
414
+ patient_id: str,
415
+ note: Note,
416
+ current_user: dict = Depends(get_current_user)
417
+ ):
418
+ logger.info(f"Adding note for patient {patient_id} by user {current_user.get('email')}")
419
+ if current_user.get('role') not in ['doctor', 'admin']:
420
+ logger.warning(f"Unauthorized note addition attempt by {current_user.get('email')}")
421
+ raise HTTPException(
422
+ status_code=status.HTTP_403_FORBIDDEN,
423
+ detail="Only clinicians can add notes"
424
+ )
425
+
426
+ try:
427
+ note_data = note.dict()
428
+ note_data.update({
429
+ "author": current_user.get('full_name', 'System'),
430
+ "timestamp": datetime.utcnow().isoformat()
431
+ })
432
+
433
+ result = await patients_collection.update_one(
434
+ {"$or": [
435
+ {"_id": ObjectId(patient_id)},
436
+ {"fhir_id": patient_id}
437
+ ]},
438
+ {
439
+ "$push": {"notes": note_data},
440
+ "$set": {"last_updated": datetime.utcnow().isoformat()}
441
+ }
442
+ )
443
+
444
+ if result.modified_count == 0:
445
+ logger.warning(f"Patient not found for note addition: {patient_id}")
446
+ raise HTTPException(
447
+ status_code=status.HTTP_404_NOT_FOUND,
448
+ detail="Patient not found"
449
+ )
450
+
451
+ logger.info(f"Note added successfully for patient {patient_id}")
452
+ return {"status": "success", "message": "Note added"}
453
+
454
+ except ValueError as ve:
455
+ logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
456
+ raise HTTPException(
457
+ status_code=status.HTTP_400_BAD_REQUEST,
458
+ detail="Invalid patient ID format"
459
+ )
460
  except Exception as e:
461
+ logger.error(f"Failed to add note for patient {patient_id}: {str(e)}")
462
  raise HTTPException(
463
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
464
+ detail=f"Failed to add note: {str(e)}"
465
  )
 
 
 
466
 
467
+ # Export the router as 'patients' for api.__init__.py
468
+ patients = router